Merge branch 'v0.7.0' into kdmukai/initial_multilanguage
Many missing i18n/l10n. tests all pass. screenshots work for "en" only.
@@ -6,3 +6,4 @@ src/seedsigner.egg-info/
|
||||
src/seedsigner/models/settings_definition.json
|
||||
.idea
|
||||
.coverage
|
||||
seedsigner-screenshots
|
||||
@@ -23,7 +23,7 @@ My self-directed exploration, from mid November through mid December 2022, to ca
|
||||
|
||||
# Project Summary
|
||||
|
||||
The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multi-signature wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model.
|
||||
The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multisignature (aka "multisig") wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model.
|
||||
|
||||
Additional information about the project can be found at [SeedSigner.com](https://seedsigner.com).
|
||||
|
||||
@@ -32,20 +32,22 @@ You can follow [@SeedSigner](https://twitter.com/SeedSigner) on Twitter for the
|
||||
If you have specific questions about the project, our [Telegram Group](https://t.me/joinchat/GHNuc_nhNQjLPWsS) is a great place to ask them.
|
||||
|
||||
### Feature Highlights:
|
||||
* Calculate word 12/24 of a BIP39 seed phrase
|
||||
* Create a 24-word BIP39 seed phrase with 99 dice rolls
|
||||
* Create a 24-word BIP39 seed phrase by taking a digital photo
|
||||
* Temporarily store up to 3 seed phrases while device is powered
|
||||
* Guided interface to manually create a SeedQR for instant input [(demo video here)](https://youtu.be/c1-PqTNx1vc)
|
||||
* BIP39 passphrase / word 25 support
|
||||
* Native Segwit Multisig XPUB generation w/ QR display
|
||||
* Scan and parse transaction data from animated QR codes
|
||||
* Calculate the final word (aka checksum) of a 12- or 24-word BIP39 seed phrase
|
||||
* Create a 24-word BIP39 seed phrase with 99 dice rolls or a 12-word with 50 rolls [(Verifying dice seed generation)](docs/dice_verification.md)
|
||||
* Create a 12- or 24-word BIP39 seed phrase via image entropy from the onboard camera
|
||||
* Temporarily stores seeds in memory while the device is powered; all memory is wiped when power is removed
|
||||
* SD card removable after boot to ensure no secret data can be written to it
|
||||
* Guided interface to manually transcribe a seed to the SeedQR format for instant seed loading [(demo video here)](https://youtu.be/c1-PqTNx1vc)
|
||||
* BIP39 passphrase (aka "word 25") support
|
||||
* Native Segwit Multisig XPUB generation
|
||||
* PSBT-compliant; scan and parse transaction data from animated QR codes
|
||||
* Sign transactions & transfer XPUB data using animated QR codes [(demo video here)](https://youtu.be/LPqvdQ2gSzs)
|
||||
* Live preview during photo-to-seed and QR scanning UX
|
||||
* Live preview during image entropy seed generation and QR scanning UX
|
||||
* Optimized seed word entry interface
|
||||
* Support for Bitcoin Mainnet & Testnet
|
||||
* Support for custom user-defined derivation paths
|
||||
* On-demand receive address verification
|
||||
* Address Explorer for single sig and multisig wallets
|
||||
* User-configurable QR code display density
|
||||
* Responsive, event-driven user interface
|
||||
|
||||
@@ -58,10 +60,10 @@ If you have specific questions about the project, our [Telegram Group](https://t
|
||||
* If you think SeedSigner adds value to the Bitcoin ecosystem, please help us spread the word! (tweets, pics, videos, etc.)
|
||||
|
||||
### Planned Upcoming Improvements / Functionality:
|
||||
* Single-sig and multi-sig change address verification
|
||||
* Re-imagined, graphically-focused user interface
|
||||
* Multi-language support
|
||||
* Customized Linux live-boot OS to allow MicroSD card removal
|
||||
* Significantly faster boot time
|
||||
* Reproducible builds
|
||||
* Port to MicroPython to broaden the range of compatible hardware to include low-cost microcontrollers
|
||||
* Other optimizations based on user feedback!
|
||||
|
||||
---------------
|
||||
|
||||
@@ -1,40 +1,207 @@
|
||||
## Verifying dice seed generation
|
||||
# Verifying dice seed generation
|
||||
|
||||
It is possible to do a 'dry run' to verify that seed generation has not been tempered.
|
||||
This will ensure that the derivation algorithm has not been tempered and is still same as well known algorithm used bu coldcard and ian coleman bip39 webpage.
|
||||
For example an 'evil maid' attack would be someone accesing your sdcard and change the code to only take in account 5 or 6 dice, so the 24 words would still feel random but the attacker will need to brute force only 5 or 6 dice (very easy).
|
||||
This is part of the "do not trust, verify" crypto mottos.
|
||||
The intention of this documentation is to describe how to verify the seed generation code used in SeedSigner against other independent tools, to prove that they all generate the same results, despite them using different programming languages and code libraries.<br><br>
|
||||
As it is an important step to verify all software releases being used to ensure that the installation files downloaded have not been compromised, the same is true especially for the seed generation procedure which unknowingly might not work as expected due to bugs or even on purpose.<br><br>
|
||||
This guide describes how this can be achieved.<br><br>
|
||||
As usual: Don't Trust, Verify!<br>
|
||||
<br><br>
|
||||
**Note:**<br>
|
||||
**Do NOT use this with any seed you want to use later with real funds. This exercise is only for checking that the independent codebases get to the same end result!**<br>
|
||||
**However, if you do want to check your real seedphrases you should download the Iancoleman and/or Bitcoiner.Guide tools onto an airgapped, ephemeral computer (e.g. using tails-OS) and perform these tests on there. Destroy/abandon the TailsOS afterwards.**<br>
|
||||
**Never input seed phrases that you intend to use to store real funds onto an internet-connected computer!!!**
|
||||
<br><br><br>
|
||||
|
||||
### Verifying with Ian coleman webpage
|
||||
## 99 Dice Rolls / 24 Seed Words Example
|
||||
|
||||
Go to https://iancoleman.io/bip39 and check show entropy detail:
|
||||
<img src="img/dice_entr.png">
|
||||
The following 99 dice roll results are used in the verification steps as an example for a 24 words seed:<br>
|
||||
> 655152231316521321611331544441236164664431121534415633526456254462245546236542364246312613322234612
|
||||
|
||||
And then make sure to check 'Hex' or 'base 10' (1) and 24 words as mnemonic length (2).
|
||||
Do not use 'dice' format because dice 6 will be replaced by 0.
|
||||
And then enter the 99 dices numbers in (3) :
|
||||
<img src="img/dice_type.png">
|
||||
The corresponding 24 seed words are:<br>
|
||||
> eyebrow obvious such suggest poet seven breeze blame virtual frown dynamic donor harsh pigeon express broccoli easy apology scatter force recipe shadow claim radio
|
||||
|
||||
When the 99 dice number has been entered in the seedsigner and ian coleman page, you will be able to verify that the 24 words are the same.
|
||||
(Scroll down near the end to see result values for a 50 dice rolls / 12 seed words example)
|
||||
<br><br><br>
|
||||
|
||||
## Creating seed via Dice rolls in SeedSigner (here v0.6.0)
|
||||
|
||||
#### Here in real life:
|
||||
First we create a new seed based on dice rolls in SeedSigner:<br><br>
|
||||
Power on your SeedSigner, go to the 'Tools' menu and select 'New Seed' (with the dice symbols):<br>
|
||||
<img src="img/dicedoc/sesi_tools_dice_seed.png" width="600">
|
||||
|
||||
We start with first 3 roll, it is important to always read dice roll from left to right to avoid human bias:
|
||||
<img src="img/dice_pic1.png">
|
||||
Select '24 words (99 rolls)' and on the next screen enter the dice numbers one after another:<br>
|
||||
<img src="img/dicedoc/sesi_dice_1.png" width="600">
|
||||
|
||||
Then we arrive at the 99th dice:
|
||||
<img src="img/dice_pic2.png">
|
||||
Go on until the end (99 dice roll numbers):<br>
|
||||
<img src="img/dicedoc/sesi_dice_2.png" width="600">
|
||||
|
||||
Then same 24 words! :
|
||||
<img src="img/dice_pic3.png">
|
||||
After that the 24 seed words are shown (in 6 screens of 4 words each):<br>
|
||||
<img src="img/dicedoc/sesi_seed_1.png" width="600">
|
||||
<br>**.....**<br>
|
||||
<img src="img/dicedoc/sesi_seed_2.png" width="600">
|
||||
|
||||
Now we are sure that the dice derivation is correct and we can unplug everything and do a new dice roll only on the seedsigner.
|
||||
The fingerprint for this seed is:<br>
|
||||
<img src="img/dicedoc/sesi_finger_print.png" width="600">
|
||||
<br><br><br>
|
||||
Having now created a dice-based seed in the SeedSigner, we will go on to compare those details to what appears in the:
|
||||
* Sparrow desktop wallet software
|
||||
* Ian Coleman's Mnemonic Code Converter website
|
||||
* Seed Tool website
|
||||
|
||||
### Verifying with Coldcard
|
||||
We will create a wallet to have the complete zpub and receive/change addresses to check against the two web pages.<br>
|
||||
SeedSigner currently supports BlueWallet, Nunchuk, Sparrow and Specter Desktop. Here we will use Sparrow wallet as an example.<br>
|
||||
|
||||
There is nothing specific, the algorithm are completely the same. Coldcard has a verification script in python and all explanations here:
|
||||
https://coldcard.com/docs/verifying-dice-roll-math
|
||||
Keep the SeedSigner open and the newly created seed still loaded as we will need it in the next step.
|
||||
<br><br><br>
|
||||
|
||||
## Create new wallet from seed in Sparrow Wallet to see xpub/zpub and addresses
|
||||
|
||||
Go to https://www.sparrowwallet.com/download/ and download the release version supported by your operating system.<br><br>
|
||||
Open Sparrow Wallet, go to 'File' menu and select 'New Wallet'. Enter a name (e.g. test), and click 'Create Wallet'.<br><br>
|
||||
Click 'Airgapped Hardware Wallet' (1) and click on the 'Scan' button in the SeedSigner entry (2) which will open the camera scan screen:<br>
|
||||
<kbd><img src="img/dicedoc/sparrow_wallet_1.png"></kbd>
|
||||
|
||||
On SeedSigner go to the seed just created and click 'Export Xpub':<br>
|
||||
<img src="img/dicedoc/sesi_export_xpub_1.png" width="600">
|
||||
|
||||
Follow these menu entries in SeedSigner:<br>
|
||||
> Export Xpub --> Single Sig --> Native Segwit --> Sparrow<br>
|
||||
|
||||
<img src="img/dicedoc/sesi_export_xpub_2.png" width="600">
|
||||
|
||||
Click 'Export Xpub' and SeedSigner will show an animated QR code to be scanned in Sparrow Wallet (where we are still in the wallet creation).<br>
|
||||
Scan the QR code SeedSigner is showing in Sparrow Wallet.<br><br>
|
||||
The wallet has now been created in Sparrow. Click 'Apply' button to finalize. The wallet's settings screen now looks like this:<br>
|
||||
<kbd><img src="img/dicedoc/sparrow_wallet_2.png"></kbd>
|
||||
|
||||
We will later use this to verify: (1) fingerprint, (2) zpub (click this button to switch between xpub and zpub!) and (3) addresses on the 'Addresses' tab.
|
||||
<br><br><br>
|
||||
|
||||
## Verifying with Ian Coleman BIP39 website
|
||||
|
||||
Go to https://iancoleman.io/bip39 and check 'Show entropy details' (1):<br>
|
||||
<kbd><img src="img/dicedoc/coleman_entropy.png"></kbd>
|
||||
|
||||
<br>
|
||||
Make sure to check (1) 'Hex' and (2) '24 Words' as 'Mnemonic Length'.<br>
|
||||
(Do not use 'dice' format because dice 6 will be replaced by 0).<br>
|
||||
Then enter the 99 dices numbers in (3). The corresponding seed words are shown in (4):<br>
|
||||
<kbd><img src="img/dicedoc/coleman_verify.png"></kbd>
|
||||
<br><br>
|
||||
The 24 seed words are the same in SeedSigner and the Ian Coleman tool.
|
||||
<br><br>
|
||||
|
||||
### Verification of (1) fingerprint, (2) zpub and (3) generated addresses
|
||||
**Fingerprint:**<br>
|
||||
Fingerprint is not shown in the Ian Coleman tool (so cannot be verified here)
|
||||
<br><br>
|
||||
**Zpub:**<br>
|
||||
Scroll down to the 'Derivation Path' section, click on the 'BIP84' tab (1) and find the zpub in (2):
|
||||
<kbd><img src="img/dicedoc/coleman_zpub.png"></kbd><br><br>
|
||||
Compare to zpub in Sparrow:<br>
|
||||
<kbd><img src="img/dicedoc/sparrow_zpub.png"></kbd>
|
||||
<br>
|
||||
|
||||
Zpub is the same as shown in SeedSigner and Sparrow wallet.
|
||||
<br><br>
|
||||
**Addresses:**<br>
|
||||
Scroll down to the 'Derived Addresses' section and compare the receive addresses to the ones generated in Sparrow ('Addresses' tab of the wallet):
|
||||
<kbd><img src="img/dicedoc/coleman_addresses.png"></kbd>
|
||||
<br>
|
||||
|
||||
Check that the receive addresses all match.<br><br>
|
||||
To verify the change addresses, change 'External / Internal' to 1 (1):
|
||||
<kbd><img src="img/dicedoc/coleman_change_addresses_1.png"></kbd><br><br>
|
||||
Compare the change addresses to the ones generated in Sparrow ('Addresses' tab of the wallet):
|
||||
<kbd><img src="img/dicedoc/coleman_change_addresses_2.png"></kbd>
|
||||
<br>
|
||||
|
||||
Check that the change addresses all match.
|
||||
<br><br><br>
|
||||
|
||||
## Verifying with Seed Tool website
|
||||
|
||||
Go to https://bitcoiner.guide/seed/ and click on 'Seed Generation Input' (1):<br>
|
||||
<kbd><img src="img/dicedoc/seedtool_1.png"></kbd>
|
||||
|
||||
Then click on the 'Show the Entropy Section' tab (1):<br>
|
||||
<kbd><img src="img/dicedoc/seedtool_2.png"></kbd>
|
||||
|
||||
Enter the 99 dice numbers in (1), in (2) change back to 'Hex', check that (3) is still '24 Words' and the calculated seed words are shown in (4):<br>
|
||||
<kbd><img src="img/dicedoc/seedtool_3.png"></kbd>
|
||||
|
||||
Seed words shown are the same as in SeedSigner and the Ian Coleman web tool seen before.
|
||||
<br><br>
|
||||
|
||||
### Verification of (1) fingerprint, (2) zpub and (3) generated addresses
|
||||
|
||||
**Fingerprint:**<br>
|
||||
Fingerprint can be seen here (1): <br>
|
||||
<kbd><img src="img/dicedoc/seedtool_fingerprint.png"></kbd><br><br>
|
||||
**Zpub:**<br>
|
||||
Scroll down to the 'Derived Addresses' section (1), click on it, make sure that '84' is selected for 'Purpose' (2) and check the zpub at (3):<br>
|
||||
<kbd><img src="img/dicedoc/seedtool_zpub.png"></kbd><br><br>
|
||||
Compare to zpub in Sparrow:<br>
|
||||
<kbd><img src="img/dicedoc/sparrow_zpub.png"></kbd>
|
||||
<br>
|
||||
|
||||
Zpub is the same as shown in SeedSigner, Sparrow and Ian Colemand tool.
|
||||
<br><br>
|
||||
**Addresses:**<br>
|
||||
Scroll down a little bit where the receive addresses are shown and compare to the ones generated in Sparrow ('Addresses' tab of the wallet):<br>
|
||||
<kbd><img src="img/dicedoc/seedtool_addresses.png"></kbd>
|
||||
<br>
|
||||
|
||||
Check that the receive addresses all match.<br><br>
|
||||
To verify the change addresses, change the 'Receive/Change' dropdown box to '1 (Change)' (1):
|
||||
<kbd><img src="img/dicedoc/seedtool_change_addresses_1.png"></kbd><br><br>
|
||||
Compare the change addresses to the ones generated in Sparrow ('Addresses' tab of the wallet):
|
||||
<kbd><img src="img/dicedoc/seedtool_change_addresses_2.png"></kbd>
|
||||
<br>
|
||||
|
||||
Check that the change addresses all match.
|
||||
<br><br><br>
|
||||
|
||||
## 50 Dice Rolls / 12 Seed Words Example
|
||||
|
||||
SeedSigner supports the creation of mnenomic seeds both with 12 or 24 seed words corresponding to 50 or 99 dice rolls. Below are some example result values for using 50 dice rolls only.<br><br>
|
||||
All the steps shown can be executed the same way, just select the '12 words (50 rolls)' option in SeedSigner and change the 'Mnenomic Length' dropdown boxes in both web tools to '12 Words'.<br><br>
|
||||
|
||||
50 dice roll results as an example for a 12 words seed:<br>
|
||||
> 65515223131652132161133154444123616466443112153441
|
||||
|
||||
The corresponding 12 seed words are:<br>
|
||||
> hole luggage safe present express tragic orbit shed switch metal identify path
|
||||
|
||||
Fingerprint:<br>
|
||||
> 8d9cced8
|
||||
|
||||
Zpub:<br>
|
||||
> zpub6qf9ziL759pzyhKMWaPfNSiCETkoA6oq3fbCDvXqcURiMtPnkEg3nH93W5mrSkvGPoJC9xTYZheYDsYoiYc5AkSk9iY3DkCJHkFgHMdijW6
|
||||
|
||||
Addresses:<br>
|
||||
Receive:<br>
|
||||
> bc1q00lln3r4mt4uwvg7mxv96xgpewauwmggkex2ff<br>
|
||||
> bc1q0jj2cv965f3642mv4lgq5za80jtfpkd0jhjefr<br>
|
||||
> bc1qpecssejm2678v0rknk9tsxd5fsshezfr0vr5m5<br>
|
||||
> bc1qcsl37xn5rkfz8qhcfwq5u52acxecyyfz0kv4gh<br>
|
||||
> bc1q8ehx53re0wck4m9tzek8mlnctp2ztm7jq94zm4<br>
|
||||
> ...<br>
|
||||
|
||||
Change:<br>
|
||||
> bc1qz0ckhg3m349qpmweyn5v6r6tvx2wfw4nv8h75q<br>
|
||||
> bc1qme5tu2t424ws3z69u445q0yw88vpc7fwygra4r<br>
|
||||
> bc1qznuyuc087ky7fhv4nvlmll4p586ks4ggcyt36d<br>
|
||||
> bc1qjcqxv22j0g00pehruwwh34sw5znu6vp3myaspy<br>
|
||||
> bc1q6dpfl7czd22wt0max6p09vr6lvvpag7xw9u8lc<br>
|
||||
> ...
|
||||
|
||||
<br><br>
|
||||
## Conclusion
|
||||
|
||||
What did we achieve now?<br>
|
||||
|
||||
We created a dice-based seed in SeedSigner and set up a wallet using this seed in Sparrow wallet (as this is what a seed is used for).<br><br>
|
||||
We double-checked in two different web tools implementing different methods for seed creation that what SeedSigner generates perfectly matches up with what the other tools calculate based on the same dice entropy used.<br>
|
||||
|
||||
So congratulations if the fingerprints, zpubs and addresses all match up in your example so you can be much more confident that nothing is wrong with your generated seed.
|
||||
|
||||
### Epilogue
|
||||
You can use these methods to do dry run time to time to verify that no one has changed the micro sdcard. But do not use the generated 24 words as a valid wallet, they need to be generated alone, only on the seedsigner!
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 576 KiB |
|
After Width: | Height: | Size: 505 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 34 KiB |
@@ -6,11 +6,15 @@ https://downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2
|
||||
|
||||
Best practice is to verify the downloaded .zip file containing the Raspberry Pi Lite OS matches the published SHA256 hash of the file; for additional reference that hash is: c5dad159a2775c687e9281b1a0e586f7471690ae28f2f2282c90e7d59f64273c. After verifying the file's data integrity, you can decompress the .zip file to obtain the operating system image that it contains. You can then use Balena's Etcher tool (https://www.balena.io/etcher/) to write the Raspberry Pi Lite software image to a memory card (4 GB or larger). It's important to note that an image authoring tool must be used (the operating system image cannot be simply copied into a file storage partition on the memory card).
|
||||
|
||||
The manual SeedSigner installation and configuration process requires an internet connection on the device to download the necessary libraries and code. But because the Pi Zero 1.3 does not have onboard wifi, you have two options:
|
||||
The manual SeedSigner installation and configuration process requires an internet connection on the Pi to download the necessary libraries and code.
|
||||
If your Pi does not have onboard wifi, you have two options:
|
||||
|
||||
1. Run these steps on a separate Raspberry Pi 2/3/4 or Zero W which can connect to the internet and then transfer the SD card to the Pi Zero 1.3 when complete.
|
||||
2. OR configure the Pi Zero 1.3 directly by relaying through your computer's internet connection over USB. See instructions [here](usb_relay.md).
|
||||
1. Run these steps on a separate Raspberry Pi 2/3/4 or Zero W which does have onboard Wi-Fi to connect to the internet, and then move the SD card over to the non Wi-Fi enabled Pi when complete.
|
||||
2. OR configure the non Wi-Fi enabled Pi directly by relaying through your computer's internet connection over USB. See instructions [here](usb_relay.md).
|
||||
|
||||
If your Pi does have onboard Wi-Fi, then using the Rasberry Pi Imager software will allow you to easily configure your Pi's Wi-Fi connection, as well as simultaneously write the image file. That will make your initial SSH into the Pi much easier.
|
||||
Use the Pi's onboard Wi-Fi only if you are setting up a local development environment, never for real funds or binary image creation.
|
||||
|
||||
For the following steps you'll need to either connect a keyboard & monitor to the network-connected Raspberry Pi you are working with, or SSH into the Pi if you're familiar with that process.
|
||||
|
||||
### Configure the Pi
|
||||
@@ -53,6 +57,7 @@ Set the following:
|
||||
When you exit the System Configuration tool, you will be prompted to reboot the system; allow the system to reboot and continue with these instructions.
|
||||
|
||||
|
||||
Each command should be run individually,unless its specified as a multi-line command.
|
||||
### Change the default password
|
||||
Change the system's default password from the default "raspberry". Run the command:
|
||||
```bash
|
||||
@@ -150,7 +155,7 @@ Edit your bash profile with the command `nano ~/.profile` and add the following
|
||||
```bash
|
||||
export WORKON_HOME=$HOME/.envs
|
||||
export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3
|
||||
source /home/pi/.local/bin/virtualenvwrapper.sh
|
||||
source $HOME/.local/bin/virtualenvwrapper.sh
|
||||
```
|
||||
Then `CTRL-X` and `y` to exit and save changes.
|
||||
|
||||
@@ -177,6 +182,7 @@ cd seedsigner
|
||||
```bash
|
||||
# Takes 1hr 45min on a Pi Zero 1.3
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-raspi.txt
|
||||
```
|
||||
|
||||
#### `pyzbar`
|
||||
@@ -209,13 +215,22 @@ workon seedsigner-env
|
||||
pwd
|
||||
```
|
||||
|
||||
### Optional: increase spidev buffer size
|
||||
This allows `ST7789.py` to update the LCD without performing multiple write operations because the default buffer size is 4096 bytes. The default can be changed via the `/boot/cmdline.txt` file. You will need to add `spidev.bufsiz=131072` to the end of this single lined file command.
|
||||
|
||||
Example `cmdline.txt` contents:
|
||||
```
|
||||
console=serial0,115200 console=tty1 root=PARTUUID=2fa4ba7e-02 rootfstype=ext4 elevator=deadline fsck.repair=yes rootwait modules-load=dwc2,g_ether spidev.bufsiz=131072
|
||||
```
|
||||
|
||||
### Configure `systemd` to run SeedSigner at boot:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/seedsigner.service
|
||||
```
|
||||
|
||||
Add the following contents to the text file that was created:
|
||||
Add the following contents to the text file that was created:
|
||||
If you are not using the username pi, then replace `pi` in the service section below with your username. There are 3 lines to change.
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Seedsigner
|
||||
|
||||
@@ -298,16 +298,13 @@ It's just the above process in reverse:
|
||||
12. nuclear 1210
|
||||
```
|
||||
|
||||
It would be much more difficult to manually recreate your seed from a CompactSeedQR. Tools like [zxing.org](https://zxing.org/w/decode.jspx) can help you get the binary data out as a hexidecimal string:
|
||||
It would be much more difficult to manually recreate your seed from a CompactSeedQR. Tools like [zxing.org](https://zxing.org/w/decode.jspx) or [ZBar](https://zbar.sourceforge.net/) can help you get the binary data out as a hexidecimal string:
|
||||
|
||||
<img src="img/zxing_screenshot.png">
|
||||
|
||||
All 12-word CompactSeedQRs will start with `41 0` (that specifies the binary data format and says the length of the data is 16 bytes) and end with `0 ec` (unused byte and a half).
|
||||
|
||||
All 24-word CompactSeedQRs will start with `42 0` (binary, data length is 32 bytes) and end with `0` (unused half a byte).
|
||||
|
||||
The remaining hexidecimal data is just an alternate representation of the 128-bit entropy for your 12-word mnemonic (or 256 bits for a 24-word mnemonic). Various programming tools exist to convert from hex back into mnemonic form.
|
||||
Note that for some QR decoders, zxing in particular, may return more data than the compact seed itself: the data type, the data length, and padding. For example, the zxing result from 12-word CompactSeedQRs in low error correction mode will start with `41 0` (that specifies the binary data format and says the length of the data is 16 bytes) and end with `0 ec` (unused byte and a half). Similarly, 24-word CompactSeedQRs will start with `42 0` (binary, data length is 32 bytes) and end with `0` (unused half a byte).
|
||||
|
||||
The ZBar library just returns the encoded data, without metadata or padding.
|
||||
|
||||
## Obfuscation
|
||||
Conversely, having limited support for reading binary QR codes and the complications described above are seen by some as an added security feature. Should someone steal your CompactSeedQR or take a photo of it, they'll have to be fairly savvy to know how to decode it.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
## The Rugged Pill Enclosure
|
||||
|
||||
<img src="/docs/img/Rugged_Pill_Thumb.jpg" width="600" height="400">
|
||||
|
||||
- The Rugged Pill is an enclosure designed for building a SeedSigner without the need for screws or spacers.
|
||||
- This enclosure requires use of the so called "zerocam", which allows for a more compact form factor.
|
||||
- The enclosure offers a very comfortable handling experience.
|
||||
- The included controls can be printed from PLA, but TPU is recommended.
|
||||
- Recommended shore hardness is 95A, but any TPU will do.
|
||||
- Separate files are available for single and multicolor printing.
|
||||
- Top and bottom part snap fit together.
|
||||
- SD-card removable
|
||||
- Data port is closed. (Use the separate file labeled 4_SeedHammer)
|
||||
|
||||
|
||||
### File overwiev
|
||||
|
||||
#### Single color:
|
||||
- RuggedPill_Top.stl[^1]
|
||||
- RuggedPill_Bottom.stl[^1]
|
||||
- RuggedPillBottom_4SeedHammer.stl[^1]
|
||||
|
||||
- RuggedPill_Buttons.stl[^2]
|
||||
- RuggedPill_Thumbstick.stl[^2]
|
||||
- Rugged_Pill_Trackball.stl[^2]
|
||||
|
||||
#### Multimaterial:
|
||||
- RuggedPill_Top_MMU.3mf
|
||||
- RuggedPill_Bottom_MMU.3mf
|
||||
|
||||
You only need one set of buttons and a Thumbstick or Trackball, print both since they use minmal material. Test which one you like best and use that.
|
||||
|
||||
[^1]: (Recommended Materials: PLA, PETG)
|
||||
[^2]: (Recommended Materials: TPU)
|
||||
@@ -0,0 +1,3 @@
|
||||
picamera==1.13
|
||||
RPi.GPIO==0.7.0
|
||||
spidev==3.5
|
||||
@@ -6,7 +6,5 @@ Pillow==9.1.0
|
||||
pytz==2022.1
|
||||
-e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar
|
||||
qrcode==7.3.1
|
||||
RPi.GPIO==0.7.0
|
||||
six==1.16.0
|
||||
spidev==3.5
|
||||
urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a
|
||||
|
||||
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
|
||||
|
||||
setuptools.setup(
|
||||
name="seedsigner",
|
||||
version="0.6.0",
|
||||
version="0.7.0",
|
||||
author="SeedSigner",
|
||||
author_email="author@example.com",
|
||||
description="Build an offline, airgapped Bitcoin signing device for less than $50!",
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
import time
|
||||
import logging
|
||||
|
||||
import traceback
|
||||
import gettext
|
||||
import os
|
||||
|
||||
from embit.descriptor import Descriptor
|
||||
from embit.psbt import PSBT
|
||||
from PIL.Image import Image
|
||||
from typing import List
|
||||
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.hardware.buttons import HardwareButtons
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
from seedsigner.views.screensaver import ScreensaverScreen
|
||||
from seedsigner.views.view import Destination, NotYetImplementedView, UnhandledExceptionView
|
||||
|
||||
from .models import Seed, SeedStorage, Settings, Singleton, PSBTParser
|
||||
from seedsigner.models.settings import Settings
|
||||
from seedsigner.models.singleton import Singleton
|
||||
from seedsigner.models.threads import BaseThread
|
||||
from seedsigner.views.view import Destination
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class BackStack(List[Destination]):
|
||||
class BackStack(list[Destination]):
|
||||
def __repr__(self):
|
||||
if len(self) == 0:
|
||||
return "[]"
|
||||
@@ -33,6 +29,54 @@ class BackStack(List[Destination]):
|
||||
|
||||
|
||||
|
||||
class StopFlowBasedTest(Exception):
|
||||
"""
|
||||
This is a special exception that is only raised by the test suite to stop the
|
||||
Controller's main loop. It should not be raised by any other code.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class FlowBasedTestException(Exception):
|
||||
"""
|
||||
This is a special exception that is only raised by the test suite.
|
||||
It should not be raised by any other code.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class BackgroundImportThread(BaseThread):
|
||||
def run(self):
|
||||
from importlib import import_module
|
||||
|
||||
# import seedsigner.hardware.buttons # slowly imports GPIO along the way
|
||||
|
||||
def time_import(module_name):
|
||||
last = time.time()
|
||||
import_module(module_name)
|
||||
# print(time.time() - last, module_name)
|
||||
|
||||
time_import('embit')
|
||||
time_import('seedsigner.helpers.embit_utils')
|
||||
|
||||
# Do costly initializations
|
||||
time_import('seedsigner.models.seed_storage')
|
||||
from seedsigner.models.seed_storage import SeedStorage
|
||||
Controller.get_instance()._storage = SeedStorage()
|
||||
|
||||
# Get MainMenuView ready to respond quickly
|
||||
time_import('seedsigner.views.scan_views')
|
||||
|
||||
time_import('seedsigner.views.seed_views')
|
||||
|
||||
time_import('seedsigner.views.tools_views')
|
||||
|
||||
time_import('seedsigner.views.settings_views')
|
||||
|
||||
|
||||
|
||||
class Controller(Singleton):
|
||||
"""
|
||||
The Controller is a globally available singleton that maintains SeedSigner state.
|
||||
@@ -51,29 +95,29 @@ class Controller(Singleton):
|
||||
rather than at the top in order avoid circular imports.
|
||||
"""
|
||||
|
||||
VERSION = "0.6.0"
|
||||
VERSION = "0.7.0"
|
||||
|
||||
# Declare class member vars with type hints to enable richer IDE support throughout
|
||||
# the code.
|
||||
buttons: HardwareButtons = None
|
||||
storage: SeedStorage = None
|
||||
_storage: 'SeedStorage' = None # TODO: Rename "storage" to something more indicative of its temp, in-memory state
|
||||
settings: Settings = None
|
||||
renderer: Renderer = None
|
||||
|
||||
# TODO: Refactor these flow-related attrs that survive across multiple Screens.
|
||||
# TODO: Should all in-memory flow-related attrs get wiped on MainMenuView?
|
||||
psbt: PSBT = None
|
||||
psbt_seed: Seed = None
|
||||
psbt_parser: PSBTParser = None
|
||||
psbt: 'embit.psbt.PSBT' = None
|
||||
psbt_seed: 'Seed' = None
|
||||
psbt_parser: 'PSBTParser' = None
|
||||
|
||||
unverified_address = None
|
||||
|
||||
multisig_wallet_descriptor: Descriptor = None
|
||||
multisig_wallet_descriptor: 'embit.descriptor.Descriptor' = None
|
||||
|
||||
image_entropy_preview_frames: List[Image] = None
|
||||
image_entropy_preview_frames: list[Image] = None
|
||||
image_entropy_final_image: Image = None
|
||||
|
||||
address_explorer_data: dict = None
|
||||
|
||||
sign_message_data: dict = None
|
||||
# TODO: end refactor section
|
||||
|
||||
# Destination placeholder for when we need to jump out to a side flow but intend to
|
||||
@@ -83,10 +127,12 @@ class Controller(Singleton):
|
||||
FLOW__VERIFY_MULTISIG_ADDR = "multisig_addr"
|
||||
FLOW__VERIFY_SINGLESIG_ADDR = "singlesig_addr"
|
||||
FLOW__ADDRESS_EXPLORER = "address_explorer"
|
||||
FLOW__SIGN_MESSAGE = "sign_message"
|
||||
resume_main_flow: str = None
|
||||
|
||||
back_stack: BackStack = None
|
||||
screensaver: ScreensaverScreen = None
|
||||
screensaver: 'ScreensaverScreen' = None
|
||||
toast_notification_thread: 'BaseToastOverlayManagerThread' = None
|
||||
|
||||
# Babel initialization
|
||||
gettext.install('messages', localedir='seedsigner/resources/babel')
|
||||
@@ -114,6 +160,9 @@ class Controller(Singleton):
|
||||
|
||||
each time you try to re-initialize a Controller.
|
||||
"""
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
|
||||
# Must be called before the first get_instance() call
|
||||
if cls._instance:
|
||||
raise Exception("Instance already configured")
|
||||
@@ -122,15 +171,7 @@ class Controller(Singleton):
|
||||
controller = cls.__new__(cls)
|
||||
cls._instance = controller
|
||||
|
||||
# Input Buttons
|
||||
if disable_hardware:
|
||||
controller.buttons = None
|
||||
else:
|
||||
controller.buttons = HardwareButtons.get_instance()
|
||||
|
||||
# models
|
||||
# TODO: Rename "storage" to something more indicative of its temp, in-memory state
|
||||
controller.storage = SeedStorage()
|
||||
controller.settings = Settings.get_instance()
|
||||
|
||||
controller.microsd = MicroSD.get_instance()
|
||||
@@ -143,13 +184,14 @@ class Controller(Singleton):
|
||||
# Configure the Renderer
|
||||
Renderer.configure_instance()
|
||||
|
||||
controller.screensaver = ScreensaverScreen(controller.buttons)
|
||||
|
||||
controller.back_stack = BackStack()
|
||||
|
||||
# Other behavior constants
|
||||
controller.screensaver_activation_ms = 120 * 1000
|
||||
controller.screensaver_activation_ms = 2 * 60 * 1000 # two minutes
|
||||
|
||||
background_import_thread = BackgroundImportThread()
|
||||
background_import_thread.start()
|
||||
|
||||
return cls._instance
|
||||
|
||||
|
||||
@@ -157,9 +199,18 @@ class Controller(Singleton):
|
||||
def camera(self):
|
||||
from .hardware.camera import Camera
|
||||
return Camera.get_instance()
|
||||
|
||||
|
||||
@property
|
||||
def storage(self):
|
||||
while not self._storage:
|
||||
# Wait for the BackgroundImportThread to finish initializing the storage.
|
||||
# This is a rare timing issue that likely only occurs in the test suite.
|
||||
time.sleep(0.001)
|
||||
return self._storage
|
||||
|
||||
|
||||
def get_seed(self, seed_num: int) -> Seed:
|
||||
def get_seed(self, seed_num: int) -> 'Seed':
|
||||
if seed_num < len(self.storage.seeds):
|
||||
return self.storage.seeds[seed_num]
|
||||
else:
|
||||
@@ -174,7 +225,6 @@ class Controller(Singleton):
|
||||
|
||||
|
||||
def pop_prev_from_back_stack(self):
|
||||
from .views import Destination
|
||||
if len(self.back_stack) > 0:
|
||||
# Pop the top View (which is the current View_cls)
|
||||
self.back_stack.pop()
|
||||
@@ -189,12 +239,18 @@ class Controller(Singleton):
|
||||
self.back_stack = BackStack()
|
||||
|
||||
|
||||
def start(self) -> None:
|
||||
from .views import MainMenuView, BackStackView
|
||||
from .views.screensaver import OpeningSplashScreen
|
||||
def start(self, initial_destination: Destination = None) -> None:
|
||||
"""
|
||||
The main loop of the application.
|
||||
|
||||
opening_splash = OpeningSplashScreen()
|
||||
opening_splash.start()
|
||||
* initial_destination: The first View to run. If None, the MainMenuView is
|
||||
used. Only used by the test suite.
|
||||
"""
|
||||
from seedsigner.views import MainMenuView, BackStackView
|
||||
from seedsigner.views.screensaver import OpeningSplashScreen
|
||||
from seedsigner.gui.toast import RemoveSDCardToastManagerThread
|
||||
|
||||
OpeningSplashScreen().start()
|
||||
|
||||
""" Class references can be stored as variables in python!
|
||||
|
||||
@@ -222,7 +278,14 @@ class Controller(Singleton):
|
||||
View_cls(**init_args).run()
|
||||
"""
|
||||
try:
|
||||
next_destination = Destination(MainMenuView)
|
||||
if initial_destination:
|
||||
next_destination = initial_destination
|
||||
else:
|
||||
next_destination = Destination(MainMenuView)
|
||||
|
||||
# Set up our one-time toast notification tip to remove the SD card
|
||||
self.activate_toast(RemoveSDCardToastManagerThread())
|
||||
|
||||
while True:
|
||||
# Destination(None) is a special case; render the Home screen
|
||||
if next_destination.View_cls is None:
|
||||
@@ -244,14 +307,29 @@ class Controller(Singleton):
|
||||
print(f"back_stack: {self.back_stack}")
|
||||
|
||||
try:
|
||||
# Instantiate the View class and run it
|
||||
print(f"Executing {next_destination}")
|
||||
next_destination = next_destination.run()
|
||||
|
||||
except StopFlowBasedTest:
|
||||
# This is a special exception that is only raised by the test suite
|
||||
# to stop the Controller loop and exit the test.
|
||||
return
|
||||
|
||||
except FlowBasedTestException as e:
|
||||
# This is a special exception that is only raised by the test suite.
|
||||
# Re-raise so the test suite can handle it.
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
# Display user-friendly error screen w/debugging info
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
next_destination = self.handle_exception(e)
|
||||
|
||||
if not next_destination:
|
||||
# Should only happen during dev when you hit an unimplemented option
|
||||
from seedsigner.views.view import NotYetImplementedView
|
||||
next_destination = Destination(NotYetImplementedView)
|
||||
|
||||
if next_destination.skip_current_view:
|
||||
@@ -284,16 +362,61 @@ class Controller(Singleton):
|
||||
print("-" * 30)
|
||||
|
||||
finally:
|
||||
if self.screensaver.is_running:
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
if self.is_screensaver_running:
|
||||
self.screensaver.stop()
|
||||
|
||||
if self.toast_notification_thread and self.toast_notification_thread.is_alive():
|
||||
self.toast_notification_thread.stop()
|
||||
|
||||
# Clear the screen when exiting
|
||||
print("Clearing screen, exiting")
|
||||
Renderer.get_instance().display_blank_screen()
|
||||
|
||||
|
||||
@property
|
||||
def is_screensaver_running(self):
|
||||
return self.screensaver is not None and self.screensaver.is_running
|
||||
|
||||
|
||||
def start_screensaver(self):
|
||||
# If a toast is running, tell it to give up the Renderer.lock; it will then
|
||||
# block until the screensaver is done, at which point the toast can re-acquire
|
||||
# the Renderer.lock and resume where it left off.
|
||||
if self.toast_notification_thread and self.toast_notification_thread.is_alive():
|
||||
print(f"Controller: settings toggle_render_lock for {self.toast_notification_thread.__class__.__name__}")
|
||||
self.toast_notification_thread.toggle_renderer_lock()
|
||||
|
||||
print("Controller: Starting screensaver")
|
||||
if not self.screensaver:
|
||||
# Do a lazy/late import and instantiation to reduce Controller initial startup time
|
||||
from seedsigner.views.screensaver import ScreensaverScreen
|
||||
from seedsigner.hardware.buttons import HardwareButtons
|
||||
self.screensaver = ScreensaverScreen(HardwareButtons.get_instance())
|
||||
|
||||
# Start the screensaver, but it will block until it can acquire the Renderer.lock.
|
||||
self.screensaver.start()
|
||||
print("Controller: Screensaver started")
|
||||
|
||||
|
||||
def activate_toast(self, toast_manager_thread: 'BaseToastOverlayManagerThread'):
|
||||
"""
|
||||
Ensures that the Controller has explicit control over which processes get to
|
||||
claim the Renderer.lock and which need to (potentially) release it.
|
||||
"""
|
||||
if self.is_screensaver_running:
|
||||
# New toast notifications break out of the Screensaver
|
||||
print("Controller: stopping screensaver")
|
||||
self.screensaver.stop()
|
||||
|
||||
if self.toast_notification_thread and self.toast_notification_thread.is_alive():
|
||||
# Can only run one toast at a time
|
||||
print(f"Controller: stopping {self.toast_notification_thread.__class__.__name__}")
|
||||
self.toast_notification_thread.stop()
|
||||
|
||||
self.toast_notification_thread = toast_manager_thread
|
||||
print(f"Controller: starting {self.toast_notification_thread.__class__.__name__}")
|
||||
self.toast_notification_thread.start()
|
||||
|
||||
|
||||
def handle_exception(self, e) -> Destination:
|
||||
@@ -306,6 +429,7 @@ class Controller(Singleton):
|
||||
* python file, line num, method name
|
||||
* Exception message
|
||||
"""
|
||||
from seedsigner.views.view import UnhandledExceptionView
|
||||
logger.exception(e)
|
||||
|
||||
# The final exception output line is:
|
||||
|
||||
@@ -214,8 +214,7 @@ class Keyboard:
|
||||
# Set up the rendering and state params
|
||||
self.active_keys = list(self.charset)
|
||||
|
||||
# TODO: Remove RobotoCondensed font dependency
|
||||
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold.ttf", 18)
|
||||
self.additonal_key_compact_font = Fonts.get_font("RobotoCondensed-Bold", 18)
|
||||
self.x_start = rect[0]
|
||||
self.y_start = rect[1]
|
||||
self.x_gap = 2
|
||||
|
||||
@@ -4,7 +4,8 @@ from PIL import Image, ImageDraw
|
||||
from threading import Lock
|
||||
|
||||
from seedsigner.gui.components import Fonts, GUIConstants
|
||||
from seedsigner.models import ConfigurableSingleton
|
||||
from seedsigner.hardware.ST7789 import ST7789
|
||||
from seedsigner.models.singleton import ConfigurableSingleton
|
||||
|
||||
|
||||
|
||||
@@ -20,8 +21,6 @@ class Renderer(ConfigurableSingleton):
|
||||
|
||||
@classmethod
|
||||
def configure_instance(cls):
|
||||
from seedsigner.hardware.ST7789 import ST7789
|
||||
|
||||
# Instantiate the one and only Renderer instance
|
||||
renderer = cls.__new__(cls)
|
||||
cls._instance = renderer
|
||||
@@ -35,10 +34,12 @@ class Renderer(ConfigurableSingleton):
|
||||
renderer.draw = ImageDraw.Draw(renderer.canvas)
|
||||
|
||||
|
||||
def show_image(self, image=None, alpha_overlay=None, is_background_thread: bool = False):
|
||||
"""
|
||||
is_background_thread: Only used by the ScreenshotRenderer.
|
||||
"""
|
||||
def show_image(self, image=None, alpha_overlay=None, show_direct=False):
|
||||
if show_direct:
|
||||
# Use the incoming image as the canvas and immediately render
|
||||
self.disp.ShowImage(image, 0, 0)
|
||||
return
|
||||
|
||||
if alpha_overlay:
|
||||
if image == None:
|
||||
image = self.canvas
|
||||
@@ -85,123 +86,7 @@ class Renderer(ConfigurableSingleton):
|
||||
self.disp.ShowImage(crop, 0, 0)
|
||||
|
||||
|
||||
# TODO: Remove all references
|
||||
def show_image_with_text(self, image, text, font=None, text_color="GREY", text_background=None):
|
||||
image_copy = image.copy().convert("RGBA")
|
||||
|
||||
text_overlay = Image.new("RGBA", (self.canvas_width, self.canvas_height), (255,255,255,0))
|
||||
text_overlay_draw = ImageDraw.Draw(text_overlay)
|
||||
if not font:
|
||||
font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_body_font_size())
|
||||
tw, th = text_overlay_draw.textsize(text, font=font)
|
||||
if text_background:
|
||||
text_overlay_draw.rectangle(((240 - tw) / 2 - 3, 240 - th, (240 - tw) / 2 + tw + 3, 240), fill=text_background)
|
||||
text_overlay_draw.text(((240 - tw) / 2, 240 - th - 1), text, fill=text_color, font=font)
|
||||
|
||||
self.show_image(image_copy, alpha_overlay=text_overlay)
|
||||
|
||||
|
||||
# TODO: Should probably move this to screens.py
|
||||
def draw_modal(self, lines = [], title = "", bottom = "") -> None:
|
||||
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
elif len(lines) == 2:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 3:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 4:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 55), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
self.show_image()
|
||||
|
||||
return
|
||||
|
||||
|
||||
# TODO: Should probably move this to templates.py
|
||||
def draw_prompt_yes_no(self, lines = [], title = "", bottom = "") -> None:
|
||||
self.draw_prompt_custom("", _("Yes "), _("No "), lines, title, bottom)
|
||||
return
|
||||
|
||||
|
||||
# TODO: Should probably move this to templates.py
|
||||
def draw_prompt_custom(self, a_txt, b_txt, c_txt, lines = [], title = "", bottom = "") -> None:
|
||||
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
|
||||
if len(title) > 0:
|
||||
tw, th = self.draw.textsize(title, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 2), title, fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
if len(bottom) > 0:
|
||||
tw, th = self.draw.textsize(bottom, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
self.draw.text(((240 - tw) / 2, 210), bottom, fill=self.color, font=Fonts.get_font("Assistant-Medium", 18))
|
||||
|
||||
if len(lines) == 1:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
elif len(lines) == 2:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 3:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 26))
|
||||
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 26))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
elif len(lines) == 4:
|
||||
tw, th = self.draw.textsize(lines[0], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 20), lines[0], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[1], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 90), lines[1], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[2], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 125), lines[2], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
tw, th = self.draw.textsize(lines[3], font=Fonts.get_font("Assistant-Medium", 22))
|
||||
self.draw.text(((240 - tw) / 2, 160), lines[3], fill=self.color, font=Fonts.get_font("Assistant-Medium", 22))
|
||||
|
||||
a_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(a_txt)[0]
|
||||
self.draw.text((a_x_offset, 39 + 0), a_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
|
||||
|
||||
b_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(b_txt)[0]
|
||||
self.draw.text((b_x_offset , 39 + 60), b_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
|
||||
|
||||
c_x_offset = 240 - Fonts.get_font("Assistant-Medium", 25).getsize(c_txt)[0]
|
||||
self.draw.text((c_x_offset , 39 + 120), c_txt, fill=self.color, font=Fonts.get_font("Assistant-Medium", 25))
|
||||
|
||||
self.show_image()
|
||||
|
||||
return
|
||||
|
||||
|
||||
def display_blank_screen(self):
|
||||
self.draw.rectangle((0, 0, self.canvas_width, self.canvas_height), outline=0, fill=0)
|
||||
self.show_image()
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.models.threads import BaseThread
|
||||
|
||||
from .screen import ButtonListScreen, WarningScreen
|
||||
from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerCustomIconConstants, TextArea,
|
||||
from ..components import (BtcAmount, Button, Icon, FontAwesomeIconConstants, IconTextLine, FormattedAddress, GUIConstants, Fonts, SeedSignerIconConstants, TextArea,
|
||||
calc_bezier_curve, linear_interp)
|
||||
|
||||
|
||||
@@ -661,10 +661,10 @@ class PSBTChangeDetailsScreen(ButtonListScreen):
|
||||
# Adjust the vertical spacing
|
||||
screen_y -= GUIConstants.COMPONENT_PADDING
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
# TRANSLATOR_NOTE: First variable is either "Multisig" or the fingerprint for single sig; Second is "Change" or "Addr" (change vs receive addr); Third is the address index number (e.g. your #4 receive addr).
|
||||
value_text="""{}: {} #{}""".format(_("Multisig") if self.is_multisig else self.fingerprint, _("Change") if self.is_change_derivation_path else _("Addr"), self.derivation_path_addr_index),
|
||||
icon_name=SeedSignerIconConstants.FINGERPRINT,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
is_text_centered=False,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=screen_y,
|
||||
@@ -672,8 +672,8 @@ class PSBTChangeDetailsScreen(ButtonListScreen):
|
||||
|
||||
if self.is_change_addr_verified:
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.CIRCLE_CHECK,
|
||||
icon_color="#00dd00",
|
||||
icon_name=SeedSignerIconConstants.SUCCESS,
|
||||
icon_color=GUIConstants.SUCCESS_COLOR,
|
||||
value_text=_("Address verified!"),
|
||||
is_text_centered=False,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
@@ -691,8 +691,8 @@ class PSBTFinalizeScreen(ButtonListScreen):
|
||||
super().__post_init__()
|
||||
|
||||
icon = Icon(
|
||||
icon_name=FontAwesomeIconConstants.FILE_SIGNATURE,
|
||||
icon_color=GUIConstants.LIGHT_BLUE,
|
||||
icon_name=FontAwesomeIconConstants.PAPER_PLANE,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
icon_size=GUIConstants.ICON_LARGE_BUTTON_SIZE,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING
|
||||
)
|
||||
|
||||
@@ -2,27 +2,51 @@ import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from gettext import gettext as _
|
||||
from typing import List, Tuple
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from seedsigner.gui import renderer
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.models import DecodeQR, DecodeQRStatus
|
||||
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
|
||||
from seedsigner.models.threads import BaseThread
|
||||
|
||||
from .screen import BaseScreen, BaseTopNavScreen, ButtonListScreen
|
||||
from ..components import BaseComponent, Button, GUIConstants, Fonts, IconButton, TextArea, calc_text_centering
|
||||
from .screen import BaseScreen, ButtonListScreen
|
||||
from ..components import GUIConstants, Fonts, TextArea
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanScreen(BaseScreen):
|
||||
"""
|
||||
Live preview has to balance three competing threads:
|
||||
* Camera capturing frames and making them available to read.
|
||||
* Decoder analyzing frames for QR codes.
|
||||
* Live preview display writing frames to the screen.
|
||||
|
||||
All of this would ideally be rewritten as in C/C++/Rust with python bindings for
|
||||
vastly improved performance.
|
||||
|
||||
Until then, we have to balance the resources the Pi Zero has to work with. Thus, we
|
||||
set a modest fps target for the camera: 5fps. At this pace, the decoder and the live
|
||||
display can more or less keep up with the flow of frames without much wasted effort
|
||||
in any of the threads.
|
||||
|
||||
Note: performance tuning was targeted for the Pi Zero.
|
||||
|
||||
The resolution (480x480) has not been tweaked in order to guarantee that our
|
||||
decoding abilities remain as-is. It's possible that more optimizations could be made
|
||||
here (e.g. higher res w/no performance impact? Lower res w/same decoding but faster
|
||||
performance? etc).
|
||||
|
||||
Note: This is quite a lot of important tasks for a Screen to be managing; much of
|
||||
this should probably be refactored into the Controller.
|
||||
"""
|
||||
decoder: DecodeQR = None
|
||||
instructions_text: str = _("< back | Scan a QR code")
|
||||
resolution: Tuple[int,int] = (480, 480)
|
||||
framerate: int = 12
|
||||
render_rect: Tuple[int,int,int,int] = None
|
||||
instructions_text: str = None
|
||||
resolution: tuple[int,int] = (480, 480)
|
||||
framerate: int = 6 # TODO: alternate optimization for Pi Zero 2W?
|
||||
render_rect: tuple[int,int,int,int] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -30,6 +54,8 @@ class ScanScreen(BaseScreen):
|
||||
# Initialize the base class
|
||||
super().__post_init__()
|
||||
|
||||
self.instructions_text = _("< back | ") + self.instructions_text
|
||||
|
||||
self.camera = Camera.get_instance()
|
||||
self.camera.start_video_stream_mode(resolution=self.resolution, framerate=self.framerate, format="rgb")
|
||||
|
||||
@@ -43,7 +69,7 @@ class ScanScreen(BaseScreen):
|
||||
|
||||
|
||||
class LivePreviewThread(BaseThread):
|
||||
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: Tuple[int,int,int,int]):
|
||||
def __init__(self, camera: Camera, decoder: DecodeQR, renderer: renderer.Renderer, instructions_text: str, render_rect: tuple[int,int,int,int]):
|
||||
self.camera = camera
|
||||
self.decoder = decoder
|
||||
self.renderer = renderer
|
||||
@@ -54,9 +80,7 @@ class ScanScreen(BaseScreen):
|
||||
self.render_rect = (0, 0, self.renderer.canvas_width, self.renderer.canvas_height)
|
||||
self.render_width = self.render_rect[2] - self.render_rect[0]
|
||||
self.render_height = self.render_rect[3] - self.render_rect[1]
|
||||
|
||||
print(f"render_width: {self.render_width}")
|
||||
print(f"render_height: {self.render_height}")
|
||||
self.decoder_fps = "0.0"
|
||||
|
||||
super().__init__()
|
||||
|
||||
@@ -65,44 +89,62 @@ class ScanScreen(BaseScreen):
|
||||
from timeit import default_timer as timer
|
||||
|
||||
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
|
||||
start_time = time.time()
|
||||
num_frames = 0
|
||||
show_framerate = False # enable for debugging / testing
|
||||
while self.keep_running:
|
||||
start = timer()
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
if frame is not None:
|
||||
scan_text = self.instructions_text
|
||||
num_frames += 1
|
||||
cur_time = time.time()
|
||||
cur_fps = num_frames / (cur_time - start_time)
|
||||
if self.decoder and self.decoder.get_percent_complete() > 0 and self.decoder.is_psbt:
|
||||
scan_text = str(self.decoder.get_percent_complete()) + "% Complete"
|
||||
if show_framerate:
|
||||
scan_text += f" {cur_fps:0.2f} | {self.decoder_fps}"
|
||||
else:
|
||||
if show_framerate:
|
||||
scan_text = f"{cur_fps:0.2f} | {self.decoder_fps}"
|
||||
else:
|
||||
scan_text = self.instructions_text
|
||||
|
||||
with self.renderer.lock:
|
||||
if frame.width > self.render_width or frame.height > self.render_height:
|
||||
frame = frame.resize(
|
||||
(self.render_width, self.render_height)
|
||||
(self.render_width, self.render_height),
|
||||
resample=Image.NEAREST # Use nearest neighbor for max speed
|
||||
)
|
||||
self.renderer.canvas.paste(
|
||||
frame,
|
||||
(self.render_rect[0], self.render_rect[1])
|
||||
)
|
||||
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
if scan_text:
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text=scan_text,
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
# Note: shadowed text (adding a 'stroke' outline) can
|
||||
# significantly slow down the rendering.
|
||||
# Temp solution: render a slight 1px shadow behind the text
|
||||
# TODO: Replace the instructions_text with a disappearing
|
||||
# toast/popup (see: QR Brightness UI)?
|
||||
draw.text(xy=(
|
||||
int(self.renderer.canvas_width/2 + 2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING + 2
|
||||
),
|
||||
text=scan_text,
|
||||
fill="black",
|
||||
font=instructions_font,
|
||||
anchor="ms")
|
||||
|
||||
self.renderer.show_image()
|
||||
# Render the onscreen instructions
|
||||
draw.text(xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text=scan_text,
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
anchor="ms")
|
||||
|
||||
end = timer()
|
||||
# print(f"{1.0/(end - start)} fps") # Time in seconds, e.g. 5.38091952400282
|
||||
self.renderer.show_image(frame, show_direct=True)
|
||||
# print(f" {cur_fps:0.2f} | {self.decoder_fps}")
|
||||
|
||||
time.sleep(0.05) # turn this up or down to tune performance while decoding psbt
|
||||
if self.camera._video_stream is None:
|
||||
break
|
||||
|
||||
@@ -113,49 +155,22 @@ class ScanScreen(BaseScreen):
|
||||
Screen. Once interaction starts, the display updates have to be managed in
|
||||
_run(). The live preview is an extra-complex case.
|
||||
"""
|
||||
num_frames = 0
|
||||
start_time = time.time()
|
||||
while True:
|
||||
frame = self.camera.read_video_stream()
|
||||
if frame is not None:
|
||||
status = self.decoder.add_image(frame)
|
||||
|
||||
num_frames += 1
|
||||
decoder_fps = f"{num_frames / (time.time() - start_time):0.2f}"
|
||||
self.threads[0].decoder_fps = decoder_fps
|
||||
|
||||
if status in (DecodeQRStatus.COMPLETE, DecodeQRStatus.INVALID):
|
||||
self.camera.stop_video_stream_mode()
|
||||
break
|
||||
|
||||
# TODO: KEY_UP gives control to NavBar; use its back arrow to cancel
|
||||
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_RIGHT) or self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
|
||||
self.camera.stop_video_stream_mode()
|
||||
break
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsUpdatedScreen(ButtonListScreen):
|
||||
config_name: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.title = _("Settings QR")
|
||||
self.is_bottom_list = True
|
||||
self.button_data = [_("Home")]
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
start_y = self.top_nav.height + 20
|
||||
if self.config_name:
|
||||
self.config_name_textarea = TextArea(
|
||||
text=f'"{self.config_name}"',
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
)
|
||||
self.components.append(self.config_name_textarea)
|
||||
start_y = self.config_name_textarea.screen_y + 50
|
||||
|
||||
self.components.append(TextArea(
|
||||
text=_("Settings imported successfully!"),
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
))
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ from dataclasses import dataclass
|
||||
from gettext import gettext as _
|
||||
from PIL import Image, ImageDraw, ImageColor
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
from seedsigner.gui.components import (GUIConstants,
|
||||
BaseComponent, Button, Icon, IconButton, LargeIconButton,
|
||||
SeedSignerIconConstants, TopNav, TextArea, load_image)
|
||||
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants
|
||||
|
||||
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants, HardwareButtons
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
from ..components import (FontAwesomeIconConstants, GUIConstants, BaseComponent, Button, Icon, IconButton, LargeIconButton, SeedSignerCustomIconConstants, TopNav, TextArea, load_image, ToastOverlay)
|
||||
|
||||
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
|
||||
|
||||
|
||||
# Must be huge numbers to avoid conflicting with the selected_button returned by the
|
||||
@@ -678,28 +677,112 @@ class LargeButtonScreen(BaseTopNavScreen):
|
||||
|
||||
@dataclass
|
||||
class QRDisplayScreen(BaseScreen):
|
||||
qr_encoder: EncodeQR = None
|
||||
qr_encoder: 'EncodeQR' = None
|
||||
|
||||
class QRDisplayThread(BaseThread):
|
||||
def __init__(self, qr_encoder: EncodeQR, qr_brightness: ThreadsafeCounter, renderer: Renderer):
|
||||
def __init__(self, qr_encoder: 'EncodeQR', qr_brightness: ThreadsafeCounter, renderer: Renderer,
|
||||
tips_start_time: ThreadsafeCounter):
|
||||
super().__init__()
|
||||
self.qr_encoder = qr_encoder
|
||||
self.qr_brightness = qr_brightness
|
||||
self.renderer = renderer
|
||||
self.tips_start_time = tips_start_time
|
||||
|
||||
|
||||
def add_brightness_tips(self, image: Image.Image) -> None:
|
||||
# TODO: Refactor ToastOverlay to support two lines of icon + text and use
|
||||
# that instead of this more manual approach.
|
||||
|
||||
# Instantiate a temp Image and ImageDraw object to draw on
|
||||
rectangle_width = image.width
|
||||
rectangle_height = GUIConstants.COMPONENT_PADDING * 2 + GUIConstants.get_body_font_size() * 2 + GUIConstants.BODY_LINE_SPACING
|
||||
rectangle = Image.new('RGBA', (rectangle_width, rectangle_height), (0, 0, 0, 0))
|
||||
img_draw = ImageDraw.Draw(rectangle)
|
||||
|
||||
overlay_opacity = 224
|
||||
|
||||
# Create a semi-transparent background for the overlay, rounded edges, w/a 1-pixel gap from the edges
|
||||
img_draw.rounded_rectangle((1, 0, rectangle_width - 2, rectangle_height - 1), radius=8, fill=(0, 0, 0, overlay_opacity))
|
||||
|
||||
chevron_up_icon = Icon(
|
||||
image_draw=img_draw,
|
||||
canvas=rectangle,
|
||||
screen_x=GUIConstants.EDGE_PADDING*2 + 1,
|
||||
screen_y=GUIConstants.COMPONENT_PADDING + 4, # +4 fudge factor to account for where the chevron is drawn relative to baseline
|
||||
icon_name=SeedSignerIconConstants.CHEVRON_UP,
|
||||
icon_size=GUIConstants.get_body_font_size(),
|
||||
)
|
||||
chevron_up_icon.render()
|
||||
|
||||
chevron_down_icon = Icon(
|
||||
image_draw=img_draw,
|
||||
canvas=rectangle,
|
||||
screen_x=chevron_up_icon.screen_x,
|
||||
screen_y=chevron_up_icon.screen_y + chevron_up_icon.icon_size + GUIConstants.BODY_LINE_SPACING,
|
||||
icon_name=SeedSignerIconConstants.CHEVRON_DOWN,
|
||||
icon_size=chevron_up_icon.icon_size,
|
||||
)
|
||||
chevron_down_icon.render()
|
||||
|
||||
TextArea(
|
||||
image_draw=img_draw,
|
||||
canvas=rectangle,
|
||||
text="Brighter",
|
||||
font_size=GUIConstants.get_body_font_size(),
|
||||
font_name=GUIConstants.get_button_font_name(),
|
||||
background_color=(0, 0, 0, overlay_opacity),
|
||||
edge_padding=0,
|
||||
is_text_centered=False,
|
||||
auto_line_break=False,
|
||||
width=int(rectangle_width/2),
|
||||
screen_x=chevron_up_icon.screen_x + GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
screen_y=chevron_up_icon.screen_y - 2, # -2 to account for Icon's positioning
|
||||
allow_text_overflow=False
|
||||
).render()
|
||||
|
||||
TextArea(
|
||||
image_draw=img_draw,
|
||||
canvas=rectangle,
|
||||
text="Darker",
|
||||
font_size=GUIConstants.get_body_font_size(),
|
||||
font_name=GUIConstants.get_button_font_name(),
|
||||
background_color=(0, 0, 0, overlay_opacity),
|
||||
edge_padding=0,
|
||||
is_text_centered=False,
|
||||
auto_line_break=False,
|
||||
width=int(rectangle_width/2),
|
||||
screen_x=chevron_down_icon.screen_x + GUIConstants.ICON_INLINE_FONT_SIZE,
|
||||
screen_y=chevron_down_icon.screen_y - 2, # -2 to account for Icon's positioning
|
||||
allow_text_overflow=False
|
||||
).render()
|
||||
|
||||
# Write our temp Image onto the main image
|
||||
image.paste(rectangle, (0, image.height - rectangle_height - 1), rectangle)
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.models.settings import Settings
|
||||
settings = Settings.get_instance()
|
||||
cur_brightness_setting = settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS)
|
||||
show_brightness_tips = cur_brightness_setting == SettingsConstants.OPTION__ENABLED
|
||||
|
||||
# Loop whether the QR is a single frame or animated; each loop might adjust
|
||||
# brightness setting.
|
||||
while self.keep_running:
|
||||
# convert the self.qr_brightness integer (31-255) into hex triplets
|
||||
hex_color = (hex(self.qr_brightness.cur_count).split('x')[1]) * 3
|
||||
image = self.qr_encoder.next_part_image(240,240, border=2, background_color=hex_color)
|
||||
image = self.qr_encoder.next_part_image(240, 240, border=2, background_color=hex_color)
|
||||
|
||||
# Display the brightness tips toast
|
||||
duration = 10 ** 9 * 1.2 # 1.2 seconds
|
||||
if show_brightness_tips and time.time_ns() - self.tips_start_time.cur_count < duration:
|
||||
self.add_brightness_tips(image)
|
||||
|
||||
with self.renderer.lock:
|
||||
self.renderer.show_image(image)
|
||||
|
||||
# Target n held frames per second before rendering next QR image
|
||||
time.sleep(5/30.0)
|
||||
time.sleep(5 / 30.0)
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -708,12 +791,15 @@ class QRDisplayScreen(BaseScreen):
|
||||
|
||||
# Shared coordination var so the display thread can detect success
|
||||
settings = Settings.get_instance()
|
||||
self.qr_brightness = ThreadsafeCounter(initial_value=settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS))
|
||||
self.qr_brightness = ThreadsafeCounter(
|
||||
initial_value=settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS))
|
||||
self.tips_start_time = ThreadsafeCounter(initial_value=time.time_ns())
|
||||
|
||||
self.threads.append(QRDisplayScreen.QRDisplayThread(
|
||||
qr_encoder=self.qr_encoder,
|
||||
qr_brightness=self.qr_brightness,
|
||||
renderer=self.renderer,
|
||||
tips_start_time=self.tips_start_time
|
||||
))
|
||||
|
||||
|
||||
@@ -734,10 +820,12 @@ class QRDisplayScreen(BaseScreen):
|
||||
if user_input == HardwareButtonsConstants.KEY_DOWN:
|
||||
# Reduce QR code background brightness
|
||||
self.qr_brightness.set_value(max(31, self.qr_brightness.cur_count - 31))
|
||||
self.tips_start_time.set_value(time.time_ns())
|
||||
|
||||
elif user_input == HardwareButtonsConstants.KEY_UP:
|
||||
# Incrase QR code background brightness
|
||||
self.qr_brightness.set_value(min(self.qr_brightness.cur_count + 31, 255))
|
||||
self.tips_start_time.set_value(time.time_ns())
|
||||
|
||||
else:
|
||||
# Any other input exits the screen
|
||||
@@ -753,12 +841,14 @@ class QRDisplayScreen(BaseScreen):
|
||||
@dataclass
|
||||
class LargeIconStatusScreen(ButtonListScreen):
|
||||
title: str = None
|
||||
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_CHECK
|
||||
status_icon_name: str = SeedSignerIconConstants.SUCCESS
|
||||
status_icon_size: int = GUIConstants.ICON_PRIMARY_SCREEN_SIZE
|
||||
status_color: str = GUIConstants.SUCCESS_COLOR
|
||||
status_headline: str = None
|
||||
text: str = "" # The body text of the screen
|
||||
text_edge_padding: int = GUIConstants.EDGE_PADDING
|
||||
button_data: list = None
|
||||
allow_text_overflow: bool = False
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -779,7 +869,7 @@ class LargeIconStatusScreen(ButtonListScreen):
|
||||
self.status_icon.screen_x = int((self.canvas_width - self.status_icon.width) / 2)
|
||||
self.components.append(self.status_icon)
|
||||
|
||||
next_y = self.status_icon.screen_y + self.status_icon.height + 4
|
||||
next_y = self.status_icon.screen_y + self.status_icon.height + int(GUIConstants.COMPONENT_PADDING/2)
|
||||
if self.status_headline:
|
||||
self.warning_headline_textarea = TextArea(
|
||||
text=self.status_headline,
|
||||
@@ -794,6 +884,7 @@ class LargeIconStatusScreen(ButtonListScreen):
|
||||
height=self.buttons[0].screen_y - next_y,
|
||||
text=self.text,
|
||||
width=self.canvas_width,
|
||||
edge_padding=self.text_edge_padding, # Don't render all the way up to the far left/right edges
|
||||
screen_y=next_y,
|
||||
))
|
||||
|
||||
@@ -866,6 +957,7 @@ class WarningEdgesThread(BaseThread):
|
||||
@dataclass
|
||||
class WarningEdgesMixin:
|
||||
status_color: str = GUIConstants.WARNING_COLOR
|
||||
text_edge_padding: int = 2 * GUIConstants.EDGE_PADDING
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
@@ -876,12 +968,17 @@ class WarningEdgesMixin:
|
||||
|
||||
@dataclass
|
||||
class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
|
||||
status_icon_name: str = SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION
|
||||
title: str = None
|
||||
status_icon_name: str = SeedSignerIconConstants.WARNING
|
||||
status_color: str = "yellow"
|
||||
status_headline: str = "Privacy Leak!" # The colored text under the alert icon
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.title:
|
||||
self.title = _("Caution")
|
||||
|
||||
if not self.status_headline:
|
||||
self.status_headline = _("Privacy Leak!")
|
||||
|
||||
if not self.button_data:
|
||||
self.button_data = [_("I Understand")]
|
||||
@@ -892,6 +989,7 @@ class WarningScreen(WarningEdgesMixin, LargeIconStatusScreen):
|
||||
|
||||
@dataclass
|
||||
class DireWarningScreen(WarningScreen):
|
||||
status_headline: str = "Classified Info!" # The colored text under the alert icon
|
||||
status_color: str = GUIConstants.DIRE_WARNING_COLOR
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -997,7 +1095,7 @@ class KeyboardScreen(BaseTopNavScreen):
|
||||
|
||||
# Render the right button panel (only has a Key3 "Save" button)
|
||||
self.save_button = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.SOLID_CIRCLE_CHECK,
|
||||
icon_name=SeedSignerIconConstants.CHECK,
|
||||
icon_color=GUIConstants.SUCCESS_COLOR,
|
||||
width=right_panel_buttons_width,
|
||||
screen_x=hw_button_x,
|
||||
@@ -1160,37 +1258,11 @@ class KeyboardScreen(BaseTopNavScreen):
|
||||
"""
|
||||
return False
|
||||
|
||||
class MicroSDToastScreen(BaseScreen):
|
||||
"""
|
||||
This screen is an overlay with special behavior with the ToastOverlay component. The ToastOverlay component overides all button
|
||||
input and captures the existing screen content and stashes it to be restored once X second passes or any button is pressed. The
|
||||
display method on this screen will not complete until after the ToastOverlay render method is complete it's takeover of the screen.
|
||||
"""
|
||||
def __init__(self, action):
|
||||
self.action = action
|
||||
self.toast = None
|
||||
super().__init__()
|
||||
|
||||
def _run(self):
|
||||
return
|
||||
|
||||
def _render(self):
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
|
||||
if self.action == MicroSD.ACTION__REMOVED:
|
||||
|
||||
self.toast = ToastOverlay(
|
||||
icon_name=FontAwesomeIconConstants.SDCARD,
|
||||
color=GUIConstants.NOTIFICATION_COLOR,
|
||||
label_text=_("MicroSD removed")
|
||||
)
|
||||
|
||||
elif self.action == MicroSD.ACTION__INSERTED:
|
||||
|
||||
self.toast = ToastOverlay(
|
||||
icon_name=FontAwesomeIconConstants.SDCARD,
|
||||
color=GUIConstants.NOTIFICATION_COLOR,
|
||||
label_text=_("MicroSD inserted")
|
||||
)
|
||||
|
||||
self.toast.render()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MainMenuScreen(LargeButtonScreen):
|
||||
# Override LargeButtonScreen defaults
|
||||
title_font_size: int = 26
|
||||
show_back_button: bool = False
|
||||
show_power_button: bool = True
|
||||
|
||||
@@ -10,11 +10,9 @@ from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.helpers.qr import QR
|
||||
from seedsigner.models.threads import BaseThread, ThreadsafeCounter
|
||||
|
||||
from seedsigner.models.seed import Seed
|
||||
|
||||
from .screen import RET_CODE__BACK_BUTTON, BaseScreen, BaseTopNavScreen, ButtonListScreen, KeyboardScreen, WarningEdgesMixin
|
||||
from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, Icon, IconButton,
|
||||
IconTextLine, SeedSignerCustomIconConstants, TextArea, GUIConstants)
|
||||
from ..components import (Button, FontAwesomeIconConstants, Fonts, FormattedAddress, IconButton,
|
||||
IconTextLine, SeedSignerIconConstants, TextArea, GUIConstants, reflow_text_into_pages)
|
||||
|
||||
from seedsigner.gui.keyboard import Keyboard, TextEntryDisplay
|
||||
from seedsigner.hardware.buttons import HardwareButtons, HardwareButtonsConstants
|
||||
@@ -418,8 +416,8 @@ class SeedFinalizeScreen(ButtonListScreen):
|
||||
super().__post_init__()
|
||||
|
||||
self.fingerprint_icontl = IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
icon_name=SeedSignerIconConstants.FINGERPRINT,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
icon_size=GUIConstants.ICON_FONT_SIZE + 12,
|
||||
label_text=_("fingerprint"),
|
||||
value_text=self.fingerprint,
|
||||
@@ -438,8 +436,8 @@ class SeedOptionsScreen(ButtonListScreen):
|
||||
has_passphrase: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.top_nav_icon_name = SeedSignerCustomIconConstants.FINGERPRINT
|
||||
self.top_nav_icon_color = "blue"
|
||||
self.top_nav_icon_name = SeedSignerIconConstants.FINGERPRINT
|
||||
self.top_nav_icon_color = GUIConstants.INFO_COLOR
|
||||
self.title = self.fingerprint
|
||||
self.is_button_text_centered = False
|
||||
self.is_bottom_list = True
|
||||
@@ -519,7 +517,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen):
|
||||
for index, word in enumerate(self.words):
|
||||
draw.rounded_rectangle(
|
||||
(number_box_x, number_box_y, number_box_x + number_box_width, number_box_y + number_box_height),
|
||||
fill="#202020",
|
||||
fill=GUIConstants.BUTTON_BACKGROUND_COLOR,
|
||||
radius=5 * supersampling_factor
|
||||
)
|
||||
baseline_y = number_box_y + number_box_height - int((number_box_height - number_height)/2)
|
||||
@@ -527,7 +525,7 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen):
|
||||
(number_box_x + int(number_box_width/2), baseline_y),
|
||||
font=number_font,
|
||||
text=str(self.page_index * words_per_page + index + 1),
|
||||
fill="#0084ff",
|
||||
fill=GUIConstants.INFO_COLOR,
|
||||
anchor="ms" # Middle (centered), baSeline
|
||||
)
|
||||
|
||||
@@ -549,6 +547,22 @@ class SeedWordsScreen(WarningEdgesMixin, ButtonListScreen):
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
|
||||
def __post_init__(self):
|
||||
self.title = _("BIP-85 Index")
|
||||
self.user_input = ""
|
||||
|
||||
# Specify the keys in the keyboard
|
||||
self.rows = 3
|
||||
self.cols = 5
|
||||
self.keys_charset = "0123456789"
|
||||
self.show_save_button = True
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedWordsBackupTestPromptScreen(ButtonListScreen):
|
||||
def __post_init__(self):
|
||||
@@ -600,8 +614,9 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
|
||||
|
||||
# Set up the fingerprint and passphrase displays
|
||||
self.fingerprint_line = IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
icon_name=SeedSignerIconConstants.FINGERPRINT,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
# TRANSLATOR_NOTE: Short for "BIP32 Master Fingerprint"
|
||||
label_text=_("Fingerprint"),
|
||||
value_text=self.fingerprint,
|
||||
screen_x=GUIConstants.COMPONENT_PADDING,
|
||||
@@ -610,7 +625,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
|
||||
self.components.append(self.fingerprint_line)
|
||||
|
||||
self.derivation_line = IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.PATH,
|
||||
icon_name=SeedSignerIconConstants.DERIVATION,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
# TRANSLATOR_NOTE: Short for "Derivation Path"
|
||||
label_text=_("Derivation"),
|
||||
value_text=self.derivation_path,
|
||||
@@ -621,6 +637,8 @@ class SeedExportXpubDetailsScreen(WarningEdgesMixin, ButtonListScreen):
|
||||
|
||||
self.xpub_line = IconTextLine(
|
||||
icon_name=FontAwesomeIconConstants.X,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
# TRANSLATOR_NOTE: Short for "BIP32 Extended Public Key"
|
||||
label_text=_("Xpub"),
|
||||
value_text=f"{self.xpub[:18]}...",
|
||||
font_name=GUIConstants.FIXED_WIDTH_FONT_NAME,
|
||||
@@ -809,7 +827,7 @@ class SeedAddPassphraseScreen(BaseTopNavScreen):
|
||||
)
|
||||
|
||||
self.hw_button3 = IconButton(
|
||||
icon_name=FontAwesomeIconConstants.SOLID_CIRCLE_CHECK,
|
||||
icon_name=SeedSignerIconConstants.CHECK,
|
||||
icon_color=GUIConstants.SUCCESS_COLOR,
|
||||
width=self.right_panel_buttons_width,
|
||||
screen_x=hw_button_x,
|
||||
@@ -1024,41 +1042,14 @@ class SeedReviewPassphraseScreen(ButtonListScreen):
|
||||
|
||||
super().__post_init__()
|
||||
|
||||
fingerprint_change_label = TextArea(
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerIconConstants.FINGERPRINT,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
# TRANSLATOR_NOTE: Describes the effect of applying a BIP-39 passphrase; it changes the seed's fingerprint
|
||||
text=_("changes seed:"),
|
||||
font_size=GUIConstants.LABEL_FONT_SIZE,
|
||||
font_color=GUIConstants.LABEL_FONT_COLOR,
|
||||
is_text_centered=False,
|
||||
screen_x=int(1.25*GUIConstants.EDGE_PADDING),
|
||||
screen_y=self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.get_body_font_size()*2.25)
|
||||
)
|
||||
self.components.append(fingerprint_change_label)
|
||||
|
||||
screen_y = fingerprint_change_label.screen_y + fingerprint_change_label.height + int(GUIConstants.COMPONENT_PADDING/2)
|
||||
self.components.append(TextArea(
|
||||
text=self.fingerprint_without,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=GUIConstants.get_body_font_size() + 4,
|
||||
font_color=GUIConstants.LABEL_FONT_COLOR,
|
||||
is_text_centered=False,
|
||||
screen_x=int(1.25*GUIConstants.EDGE_PADDING),
|
||||
screen_y=screen_y,
|
||||
))
|
||||
self.components.append(Icon(
|
||||
icon_name=FontAwesomeIconConstants.CARET_RIGHT,
|
||||
icon_size=GUIConstants.get_body_font_size() + 4,
|
||||
icon_color=GUIConstants.LIGHT_BLUE,
|
||||
screen_x=self.components[-1].screen_x + self.components[-1].width + 2*GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=screen_y,
|
||||
))
|
||||
self.components.append(TextArea(
|
||||
text=self.fingerprint_with,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=GUIConstants.get_body_font_size() + 4,
|
||||
is_text_centered=False,
|
||||
screen_x=self.components[-1].screen_x + self.components[-1].width,
|
||||
screen_y=screen_y,
|
||||
label_text=_("changes fingerprint"),
|
||||
value_text=f"{self.fingerprint_without} >> {self.fingerprint_with}",
|
||||
is_text_centered=True,
|
||||
screen_y = self.buttons[0].screen_y - GUIConstants.COMPONENT_PADDING - int(GUIConstants.get_body_font_size()*2.5)
|
||||
))
|
||||
|
||||
if self.passphrase != self.passphrase.strip() or " " in self.passphrase:
|
||||
@@ -1380,7 +1371,7 @@ class AddressVerificationSigTypeScreen(ButtonListScreen):
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedSingleSigAddressVerificationSelectSeedScreen(ButtonListScreen):
|
||||
class SeedSelectSeedScreen(ButtonListScreen):
|
||||
text: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -1536,16 +1527,75 @@ class MultisigWalletDescriptorScreen(ButtonListScreen):
|
||||
auto_line_break=True,
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedBIP85SelectChildIndexScreen(KeyboardScreen):
|
||||
class SeedSignMessageConfirmMessageScreen(ButtonListScreen):
|
||||
page_num: int = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.title = _("BIP-85 Index")
|
||||
self.user_input = ""
|
||||
from seedsigner.controller import Controller
|
||||
renderer = Renderer.get_instance()
|
||||
start_y = GUIConstants.TOP_NAV_HEIGHT + GUIConstants.COMPONENT_PADDING
|
||||
end_y = renderer.canvas_height - GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT - GUIConstants.COMPONENT_PADDING
|
||||
message_height = end_y - start_y
|
||||
|
||||
# Specify the keys in the keyboard
|
||||
self.rows = 3
|
||||
self.cols = 5
|
||||
self.keys_charset = "0123456789"
|
||||
self.show_save_button = True
|
||||
self.sign_message_data = Controller.get_instance().sign_message_data
|
||||
if "paged_message" not in self.sign_message_data:
|
||||
paged = reflow_text_into_pages(
|
||||
text=self.sign_message_data["message"],
|
||||
width=renderer.canvas_width - 2*GUIConstants.EDGE_PADDING,
|
||||
height=message_height,
|
||||
)
|
||||
self.sign_message_data["paged_message"] = paged
|
||||
|
||||
if self.page_num >= len(self.sign_message_data["paged_message"]):
|
||||
raise Exception("Bug in paged_message calculation")
|
||||
|
||||
if len(self.sign_message_data["paged_message"]) == 1:
|
||||
self.title = "Review Message"
|
||||
else:
|
||||
self.title = f"""Message (pt {self.page_num + 1}/{len(self.sign_message_data["paged_message"])})"""
|
||||
self.is_bottom_list = True
|
||||
self.is_button_text_centered = True
|
||||
self.button_data = ["Next"]
|
||||
super().__post_init__()
|
||||
|
||||
message_display = TextArea(
|
||||
text=self.sign_message_data["paged_message"][self.page_num],
|
||||
is_text_centered=False,
|
||||
allow_text_overflow=True,
|
||||
screen_y=start_y,
|
||||
)
|
||||
self.components.append(message_display)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedSignMessageConfirmAddressScreen(ButtonListScreen):
|
||||
derivation_path: str = None
|
||||
address: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.title = "Confirm Address"
|
||||
self.is_bottom_list = True
|
||||
self.is_button_text_centered = True
|
||||
self.button_data = ["Sign Message"]
|
||||
super().__post_init__()
|
||||
|
||||
derivation_path_display = IconTextLine(
|
||||
icon_name=SeedSignerIconConstants.DERIVATION,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
label_text="derivation path",
|
||||
value_text=self.derivation_path,
|
||||
is_text_centered=True,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
|
||||
)
|
||||
self.components.append(derivation_path_display)
|
||||
|
||||
address_display = FormattedAddress(
|
||||
address=self.address,
|
||||
max_lines=3,
|
||||
screen_y=derivation_path_display.screen_y + derivation_path_display.height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
)
|
||||
self.components.append(address_display)
|
||||
|
||||
@@ -297,9 +297,42 @@ class DonateScreen(BaseTopNavScreen):
|
||||
|
||||
self.components.append(TextArea(
|
||||
text="seedsigner.com",
|
||||
font_name=GUIConstants.BODY_FONT_NAME["default"],
|
||||
font_name=GUIConstants.get_body_font_name(),
|
||||
font_size=28,
|
||||
font_color=GUIConstants.ACCENT_COLOR,
|
||||
supersampling_factor=1,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
))
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsQRConfirmationScreen(ButtonListScreen):
|
||||
config_name: str = None
|
||||
title: str = "Settings QR"
|
||||
status_message: str = "Settings updated..."
|
||||
is_bottom_list: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
# Customize defaults
|
||||
self.button_data = ["Home"]
|
||||
self.show_back_button = False
|
||||
super().__post_init__()
|
||||
|
||||
start_y = self.top_nav.height + 20
|
||||
if self.config_name:
|
||||
self.config_name_textarea = TextArea(
|
||||
text=f'"{self.config_name}"',
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
)
|
||||
self.components.append(self.config_name_textarea)
|
||||
start_y = self.config_name_textarea.screen_y + 50
|
||||
|
||||
self.components.append(TextArea(
|
||||
text=self.status_message,
|
||||
is_text_centered=True,
|
||||
auto_line_break=True,
|
||||
screen_y=start_y
|
||||
))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
|
||||
from dataclasses import dataclass
|
||||
from gettext import gettext as _
|
||||
from typing import Any
|
||||
from PIL.Image import Image
|
||||
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerCustomIconConstants, TextArea
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, Fonts, GUIConstants, IconTextLine, SeedSignerIconConstants, TextArea
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen, ButtonListScreen, KeyboardScreen
|
||||
from seedsigner.hardware.buttons import HardwareButtonsConstants
|
||||
@@ -18,7 +19,7 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
|
||||
super().__post_init__()
|
||||
|
||||
self.camera = Camera.get_instance()
|
||||
self.camera.start_video_stream_mode(resolution=(240, 240), framerate=24, format="rgb")
|
||||
self.camera.start_video_stream_mode(resolution=(self.canvas_width, self.canvas_height), framerate=24, format="rgb")
|
||||
|
||||
|
||||
def _run(self):
|
||||
@@ -28,28 +29,6 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
|
||||
instructions_font = Fonts.get_font(GUIConstants.get_body_font_name(), GUIConstants.get_button_font_size())
|
||||
|
||||
while True:
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
if frame is not None:
|
||||
self.renderer.canvas.paste(frame)
|
||||
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
# TRANSLATOR_NOTE: Instructions when taking a picture; "back" should point left.
|
||||
text=_("< back | click joystick"),
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
self.renderer.show_image()
|
||||
|
||||
if len(preview_images) < max_entropy_frames:
|
||||
preview_images.append(frame)
|
||||
|
||||
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_LEFT):
|
||||
# Have to manually update last input time since we're not in a wait_for loop
|
||||
self.hw_inputs.update_last_input_time()
|
||||
@@ -57,7 +36,15 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
|
||||
self.camera.stop_video_stream_mode()
|
||||
return RET_CODE__BACK_BUTTON
|
||||
|
||||
elif self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS):
|
||||
frame = self.camera.read_video_stream(as_image=True)
|
||||
|
||||
if frame is None:
|
||||
# Camera probably isn't ready yet
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
# Check for joystick click to take final entropy image
|
||||
if self.hw_inputs.check_for_low(HardwareButtonsConstants.KEY_PRESS):
|
||||
# Have to manually update last input time since we're not in a wait_for loop
|
||||
self.hw_inputs.update_last_input_time()
|
||||
self.camera.stop_video_stream_mode()
|
||||
@@ -80,6 +67,29 @@ class ToolsImageEntropyLivePreviewScreen(BaseScreen):
|
||||
|
||||
return preview_images
|
||||
|
||||
# If we're still here, it's just another preview frame loop
|
||||
self.renderer.canvas.paste(frame)
|
||||
|
||||
self.renderer.draw.text(
|
||||
xy=(
|
||||
int(self.renderer.canvas_width/2),
|
||||
self.renderer.canvas_height - GUIConstants.EDGE_PADDING
|
||||
),
|
||||
text="< back | click joystick",
|
||||
fill=GUIConstants.BODY_FONT_COLOR,
|
||||
font=instructions_font,
|
||||
stroke_width=4,
|
||||
stroke_fill=GUIConstants.BACKGROUND_COLOR,
|
||||
anchor="ms"
|
||||
)
|
||||
self.renderer.show_image()
|
||||
|
||||
if len(preview_images) == max_entropy_frames:
|
||||
# Keep a moving window of the last n preview frames; pop the oldest
|
||||
# before we add the currest frame.
|
||||
preview_images.pop(0)
|
||||
preview_images.append(frame)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -167,7 +177,7 @@ class ToolsCalcFinalWordFinalizePromptScreen(ButtonListScreen):
|
||||
self.components.append(TextArea(
|
||||
# TRANSLATOR_NOTE: Number of BIP-39 seed words, and the entropy -- in bits, contained within.
|
||||
text=_("The {}th word is built from {} more entropy bits plus auto-calculated checksum.").format(self.mnemonic_length, self.num_entropy_bits),
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=self.top_nav.height + int(GUIConstants.COMPONENT_PADDING/2),
|
||||
))
|
||||
|
||||
|
||||
@@ -246,24 +256,27 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
|
||||
self.components.append(TextArea(
|
||||
# TRANSLATOR_NOTE: A labeled presentation of a user-supplied value
|
||||
text=_('Your input: "{}"').format(selection_text),
|
||||
screen_y=self.top_nav.height,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING - 2, # Nudge to last line doesn't get too close to "Next" button
|
||||
height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
|
||||
))
|
||||
|
||||
# ...and that entropy's associated 11 bits
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
self.components.append(TextArea(
|
||||
screen_y = self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING
|
||||
first_bits_line = TextArea(
|
||||
text=keeper_selected_bits,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=bit_display_x,
|
||||
screen_y=screen_y,
|
||||
height=bit_font_height,
|
||||
is_text_centered=False,
|
||||
))
|
||||
)
|
||||
self.components.append(first_bits_line)
|
||||
|
||||
# Render the least significant bits that will be replaced by the checksum in a
|
||||
# de-emphasized font color.
|
||||
if "_" in discard_selected_bits:
|
||||
screen_y += int(first_bits_line.height/2) # center the underscores vertically like hypens
|
||||
self.components.append(TextArea(
|
||||
text=discard_selected_bits,
|
||||
font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
@@ -272,7 +285,6 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
|
||||
edge_padding=0,
|
||||
screen_x=checksum_x,
|
||||
screen_y=screen_y,
|
||||
height=bit_font_height,
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
@@ -281,7 +293,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
|
||||
# TRANSLATOR_NOTE: A function of "x" to be used for detecting errors in "x"
|
||||
text=_("Checksum"),
|
||||
edge_padding=0,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=first_bits_line.screen_y + first_bits_line.height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
# ...and its actual bits. Prepend spacers to keep vertical alignment
|
||||
@@ -297,8 +309,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
|
||||
font_size=bit_font_size,
|
||||
edge_padding=0,
|
||||
screen_x=bit_display_x,
|
||||
screen_y=screen_y,
|
||||
height=bit_font_height,
|
||||
screen_y=screen_y + int(first_bits_line.height/2), # center the underscores vertically like hypens
|
||||
is_text_centered=False,
|
||||
))
|
||||
|
||||
@@ -319,6 +330,7 @@ class ToolsCalcFinalWordScreen(ButtonListScreen):
|
||||
# TRANSLATOR_NOTE: labeled presentation of the last word in a BIP-39 mnemonic seed phrase.
|
||||
text=_('Final Word: "{}"').format(self.actual_final_word),
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
height_ignores_below_baseline=True, # Keep the next line (bits display) snugged up, regardless of text rendering below the baseline
|
||||
))
|
||||
|
||||
# Once again show the bits that came from the user's entropy...
|
||||
@@ -375,8 +387,8 @@ class ToolsCalcFinalWordDoneScreen(ButtonListScreen):
|
||||
))
|
||||
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
icon_name=SeedSignerIconConstants.FINGERPRINT,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
# TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet
|
||||
label_text=_("fingerprint"),
|
||||
value_text=self.fingerprint,
|
||||
@@ -401,8 +413,8 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
|
||||
|
||||
if self.fingerprint:
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.FINGERPRINT,
|
||||
icon_color="blue",
|
||||
icon_name=SeedSignerIconConstants.FINGERPRINT,
|
||||
icon_color=GUIConstants.INFO_COLOR,
|
||||
# TRANSLATOR_NOTE: a label for the shortened Key-id of a BIP-32 master HD wallet
|
||||
label_text=_("Fingerprint"),
|
||||
value_text=self.fingerprint,
|
||||
@@ -412,21 +424,21 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
|
||||
|
||||
if self.script_type != SettingsConstants.CUSTOM_DERIVATION:
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.PATH,
|
||||
icon_name=SeedSignerIconConstants.DERIVATION,
|
||||
# TRANSLATOR_NOTE: a label for the derivation-path into a BIP-32 HD wallet
|
||||
label_text=_("Derivation"),
|
||||
value_text=SettingsDefinition.get_settings_entry(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(value=self.script_type),
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
else:
|
||||
self.components.append(IconTextLine(
|
||||
icon_name=SeedSignerCustomIconConstants.PATH,
|
||||
icon_name=SeedSignerIconConstants.DERIVATION,
|
||||
# l10n_note already exists.
|
||||
label_text=_("Derivation"),
|
||||
value_text=self.custom_derivation_path,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + GUIConstants.COMPONENT_PADDING,
|
||||
screen_y=self.components[-1].screen_y + self.components[-1].height + 2*GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
else:
|
||||
@@ -434,7 +446,7 @@ class ToolsAddressExplorerAddressTypeScreen(ButtonListScreen):
|
||||
# TRANSLATOR_NOTE: a label for a BIP-380-ish Output Descriptor
|
||||
label_text=_("Wallet descriptor"),
|
||||
value_text=self.wallet_descriptor_display_name,
|
||||
is_text_centered=False,
|
||||
is_text_centered=True,
|
||||
screen_x=GUIConstants.EDGE_PADDING,
|
||||
screen_y=self.top_nav.height + GUIConstants.COMPONENT_PADDING,
|
||||
))
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from seedsigner.gui.components import BaseComponent, GUIConstants, Icon, SeedSignerIconConstants, TextArea
|
||||
from seedsigner.models.threads import BaseThread
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToastOverlay(BaseComponent):
|
||||
icon_name: str = None
|
||||
color: str = GUIConstants.NOTIFICATION_COLOR
|
||||
label_text: str = None
|
||||
height: int = GUIConstants.ICON_TOAST_FONT_SIZE + 2*GUIConstants.EDGE_PADDING
|
||||
font_size: int = 19
|
||||
outline_thickness: int = 2 # pixels
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
|
||||
self.icon = Icon(
|
||||
image_draw=self.image_draw,
|
||||
canvas=self.canvas,
|
||||
screen_x=self.outline_thickness + 2*GUIConstants.EDGE_PADDING, # Push the icon further from the left edge than strictly necessary
|
||||
icon_name=self.icon_name,
|
||||
icon_size=GUIConstants.ICON_TOAST_FONT_SIZE,
|
||||
icon_color=self.color
|
||||
)
|
||||
self.icon.screen_y = self.canvas_height - self.height + int((self.height - self.icon.height)/2)
|
||||
|
||||
self.label = TextArea(
|
||||
image_draw=self.image_draw,
|
||||
canvas=self.canvas,
|
||||
text=self.label_text,
|
||||
font_size=self.font_size,
|
||||
font_color=self.color,
|
||||
edge_padding=0,
|
||||
is_text_centered=False,
|
||||
auto_line_break=True,
|
||||
width=self.canvas_width - self.icon.screen_x - self.icon.width - GUIConstants.COMPONENT_PADDING - self.outline_thickness,
|
||||
screen_x=self.icon.screen_x + self.icon.width + GUIConstants.COMPONENT_PADDING,
|
||||
allow_text_overflow=False,
|
||||
)
|
||||
|
||||
# Vertically center the message within the toast (for single- or multi-line
|
||||
# messages).
|
||||
self.label.screen_y = self.canvas_height - self.height + self.outline_thickness + int((self.height - 2*self.outline_thickness - self.label.height)/2)
|
||||
|
||||
|
||||
def render(self):
|
||||
# Render the toast's solid background
|
||||
self.image_draw.rounded_rectangle(
|
||||
(0, self.canvas_height - self.height, self.canvas_width, self.canvas_height),
|
||||
fill=GUIConstants.BACKGROUND_COLOR,
|
||||
radius=8,
|
||||
outline=self.color,
|
||||
width=self.outline_thickness,
|
||||
)
|
||||
|
||||
# Draw the toast visual elements
|
||||
self.icon.render()
|
||||
self.label.render()
|
||||
|
||||
self.renderer.show_image()
|
||||
|
||||
|
||||
|
||||
class BaseToastOverlayManagerThread(BaseThread):
|
||||
"""
|
||||
The toast notification popup consists of a gui component (`ToastOverlay`) and this
|
||||
manager thread that the Controller will use to coordinate handing off resources
|
||||
between competing toasts, the screensaver, and the current underlying Screen.
|
||||
|
||||
Controller should set BaseThread.keep_running = False to terminate the toast when it
|
||||
needs to be removed or replaced.
|
||||
|
||||
Controller should set toggle_renderer_lock = True to make the toast temporarily
|
||||
release the Renderer.lock so another process (e.g. screensaver) can use it. The toast
|
||||
thread will immediately try to reacquire the lock, but will have to block and wait
|
||||
until it's available again. Note that this thread will be unresponsive while it
|
||||
waits to reacquire the lock!
|
||||
|
||||
Note: any process can call lock.release() but it simplifies the logic to try to keep
|
||||
each process aware of whether it is currently holding the lock or not (i.e. it's
|
||||
better for the "owner" thread to release the lock itself).
|
||||
"""
|
||||
def __init__(self,
|
||||
activation_delay: int = 0, # seconds before toast is displayed
|
||||
duration: int = 3, # seconds toast is displayed
|
||||
):
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.hardware.buttons import HardwareButtons
|
||||
super().__init__()
|
||||
self.activation_delay: int = activation_delay
|
||||
self.duration: int = duration
|
||||
self._toggle_renderer_lock: bool = False
|
||||
|
||||
self.renderer = Renderer.get_instance()
|
||||
self.controller = Controller.get_instance()
|
||||
self.hw_inputs = HardwareButtons.get_instance()
|
||||
|
||||
# Special case when screensaver is running
|
||||
self.hw_inputs.override_ind = True
|
||||
|
||||
self.toast = self.instantiate_toast()
|
||||
|
||||
|
||||
def instantiate_toast(self) -> ToastOverlay:
|
||||
raise Exception("Must be implemented by subclass")
|
||||
|
||||
|
||||
def should_keep_running(self) -> bool:
|
||||
""" Placeholder for custom exit conditions """
|
||||
return True
|
||||
|
||||
|
||||
def toggle_renderer_lock(self):
|
||||
self._toggle_renderer_lock = True
|
||||
|
||||
|
||||
def run(self):
|
||||
print(f"{self.__class__.__name__}: started")
|
||||
start = time.time()
|
||||
while time.time() - start < self.activation_delay:
|
||||
if self.hw_inputs.has_any_input():
|
||||
# User has pressed a button, cancel the toast
|
||||
print(f"{self.__class__.__name__}: Canceling toast due to user input")
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
try:
|
||||
# Hold onto the Renderer lock so we're guaranteed to restore the original
|
||||
# screen before any other listener can get a screen write in.
|
||||
print(f"{self.__class__.__name__}: Acquiring lock")
|
||||
self.renderer.lock.acquire()
|
||||
print(f"{self.__class__.__name__}: Lock acquired")
|
||||
|
||||
has_rendered = False
|
||||
previous_screen_state = None
|
||||
while self.keep_running and self.should_keep_running():
|
||||
if self.hw_inputs.has_any_input():
|
||||
# User has pressed a button, hide the toast
|
||||
print(f"{self.__class__.__name__}: Exiting due to user input")
|
||||
break
|
||||
|
||||
if self._toggle_renderer_lock:
|
||||
# Controller has notified us that another process needs the lock
|
||||
print(f"{self.__class__.__name__}: Releasing lock")
|
||||
self._toggle_renderer_lock = False
|
||||
self.renderer.lock.release()
|
||||
|
||||
# pause to avoid race conditions reacquiring the lock
|
||||
while not self.renderer.lock.locked():
|
||||
# Wait for a different process to grab the lock
|
||||
time.sleep(0.1)
|
||||
|
||||
# Block while waiting to reaquire the lock
|
||||
print(f"{self.__class__.__name__}: Blocking to re-acquire lock")
|
||||
self.renderer.lock.acquire()
|
||||
print(f"{self.__class__.__name__}: Lock re-acquired")
|
||||
|
||||
if not has_rendered:
|
||||
previous_screen_state = self.renderer.canvas.copy()
|
||||
print(f"{self.__class__.__name__}: Showing toast")
|
||||
self.toast.render()
|
||||
has_rendered = True
|
||||
|
||||
if time.time() - start > self.activation_delay + self.duration and has_rendered:
|
||||
print(f"{self.__class__.__name__}: Hiding toast")
|
||||
break
|
||||
|
||||
# Free up cpu resources for main thread
|
||||
time.sleep(0.1)
|
||||
|
||||
finally:
|
||||
print(f"{self.__class__.__name__}: exiting")
|
||||
if has_rendered and self.renderer.lock.locked():
|
||||
# As far as we know, we currently hold the Renderer.lock
|
||||
self.renderer.show_image(previous_screen_state)
|
||||
print(f"{self.__class__.__name__}: restored previous screen state")
|
||||
|
||||
# We're done, release the lock
|
||||
self.renderer.lock.release()
|
||||
|
||||
|
||||
|
||||
class RemoveSDCardToastManagerThread(BaseToastOverlayManagerThread):
|
||||
def __init__(self, activation_delay=3):
|
||||
# Note: activation_delay is configurable so the screenshot generator can get the
|
||||
# toast to immediately render.
|
||||
super().__init__(
|
||||
activation_delay=activation_delay, # seconds
|
||||
duration=1e6, # seconds ("forever")
|
||||
)
|
||||
|
||||
|
||||
def instantiate_toast(self) -> ToastOverlay:
|
||||
body_font_size = GUIConstants.get_body_font_size()
|
||||
return ToastOverlay(
|
||||
icon_name=SeedSignerIconConstants.MICROSD,
|
||||
label_text="You can remove\nthe SD card now",
|
||||
font_size=body_font_size,
|
||||
height=body_font_size * 2 + GUIConstants.BODY_LINE_SPACING + GUIConstants.EDGE_PADDING,
|
||||
)
|
||||
|
||||
|
||||
def should_keep_running(self) -> bool:
|
||||
""" Custom exit condition: keep running until the SD card is removed """
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
return MicroSD.get_instance().is_inserted
|
||||
|
||||
|
||||
|
||||
class SDCardStateChangeToastManagerThread(BaseToastOverlayManagerThread):
|
||||
def __init__(self, action: str, *args, **kwargs):
|
||||
# Note: we could just directly detect the MicroSD status here, but passing it in
|
||||
# via `action` lets us simulate the state we want in the screenshot generator.
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
if action not in [MicroSD.ACTION__INSERTED, MicroSD.ACTION__REMOVED]:
|
||||
raise Exception(f"Invalid MicroSD action: {action}")
|
||||
self.message = "SD card removed" if action == MicroSD.ACTION__REMOVED else "SD card inserted"
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def instantiate_toast(self) -> ToastOverlay:
|
||||
print("instantiating toast!")
|
||||
return ToastOverlay(
|
||||
icon_name=SeedSignerIconConstants.MICROSD,
|
||||
label_text=self.message,
|
||||
)
|
||||
@@ -159,13 +159,11 @@ class ST7789(object):
|
||||
pix = arr.tobytes()
|
||||
self.SetWindows ( 0, 0, self.width, self.height)
|
||||
GPIO.output(self._dc,GPIO.HIGH)
|
||||
for i in range(0,len(pix),4096):
|
||||
self._spi.writebytes(pix[i:i+4096])
|
||||
self._spi.writebytes2(pix)
|
||||
|
||||
def clear(self):
|
||||
"""Clear contents of image buffer"""
|
||||
_buffer = [0xff]*(self.width * self.height * 2)
|
||||
self.SetWindows ( 0, 0, self.width, self.height)
|
||||
GPIO.output(self._dc,GPIO.HIGH)
|
||||
for i in range(0,len(_buffer),4096):
|
||||
self._spi.writebytes(_buffer[i:i+4096])
|
||||
self._spi.writebytes2(_buffer)
|
||||
|
||||
@@ -81,7 +81,7 @@ class HardwareButtons(Singleton):
|
||||
|
||||
while True:
|
||||
cur_time = int(time.time() * 1000)
|
||||
if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.screensaver.is_running:
|
||||
if cur_time - self.last_input_time > controller.screensaver_activation_ms and not controller.is_screensaver_running:
|
||||
# Start the screensaver. Will block execution until input detected.
|
||||
controller.start_screensaver()
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import io
|
||||
import numpy
|
||||
|
||||
from PIL import Image
|
||||
from seedsigner.models import Singleton
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
from seedsigner.hardware.pivideostream import PiVideoStream
|
||||
from seedsigner.models.settings import Settings, SettingsConstants
|
||||
from seedsigner.models.singleton import Singleton
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ class Camera(Singleton):
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
# This is the only way to access the one and only Controller
|
||||
from seedsigner.models import Settings
|
||||
if cls._instance is None:
|
||||
cls._instance = cls.__new__(cls)
|
||||
cls._instance._camera_rotation = int(Settings.get_instance().get_value(SettingsConstants.SETTING__CAMERA_ROTATION))
|
||||
|
||||
@@ -1,55 +1,71 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from seedsigner.models.singleton import Singleton
|
||||
from seedsigner.models.threads import BaseThread
|
||||
from seedsigner.models.settings import Settings
|
||||
#from seedsigner.views.view import MicroSDToastView
|
||||
from seedsigner.gui.screens.screen import MicroSDToastScreen
|
||||
|
||||
|
||||
|
||||
class MicroSD(Singleton, BaseThread):
|
||||
|
||||
ACTION__INSERTED = "add"
|
||||
ACTION__REMOVED = "remove"
|
||||
MOUNT_POINT = "/mnt/microsd"
|
||||
FIFO_PATH = "/tmp/mdev_fifo"
|
||||
FIFO_MODE = 0o600
|
||||
ACTION__INSERTED = "add"
|
||||
ACTION__REMOVED = "remove"
|
||||
|
||||
settings_handler = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
# This is the only way to access the one and only instance
|
||||
if cls._instance is None:
|
||||
# Instantiate the one and only instance
|
||||
microsd = cls.__new__(cls)
|
||||
cls._instance = microsd
|
||||
|
||||
# explicitly call BaseThread __init__ since multiple class inheritance
|
||||
BaseThread.__init__(microsd)
|
||||
|
||||
return cls._instance
|
||||
|
||||
def start_detection(self):
|
||||
self.start()
|
||||
|
||||
def run(self):
|
||||
import os
|
||||
|
||||
FIFO_PATH = "/tmp/mdev_fifo"
|
||||
FIFO_MODE = 0o600
|
||||
action = ""
|
||||
|
||||
# explicitly only microsd add/remove detection in seedsigner-os
|
||||
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
|
||||
|
||||
if os.path.exists(FIFO_PATH):
|
||||
os.remove(FIFO_PATH)
|
||||
|
||||
os.mkfifo(FIFO_PATH, FIFO_MODE)
|
||||
|
||||
while self.keep_running:
|
||||
with open(FIFO_PATH) as fifo:
|
||||
action = fifo.read()
|
||||
print(f"fifo message: {action}")
|
||||
|
||||
Settings.microsd_handler(action=action)
|
||||
|
||||
toastscreen = MicroSDToastScreen(action=action)
|
||||
toastscreen.display()
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
# This is the only way to access the one and only instance
|
||||
if cls._instance is None:
|
||||
# Instantiate the one and only instance
|
||||
microsd = cls.__new__(cls)
|
||||
cls._instance = microsd
|
||||
|
||||
# explicitly call BaseThread __init__ since multiple class inheritance
|
||||
BaseThread.__init__(microsd)
|
||||
|
||||
return cls._instance
|
||||
|
||||
|
||||
@property
|
||||
def is_inserted(self):
|
||||
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
|
||||
return os.path.exists(MicroSD.MOUNT_POINT)
|
||||
else:
|
||||
# Always True for Raspi OS
|
||||
return True
|
||||
|
||||
|
||||
def start_detection(self):
|
||||
self.start()
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui.toast import SDCardStateChangeToastManagerThread
|
||||
action = ""
|
||||
|
||||
# explicitly only microsd add/remove detection in seedsigner-os
|
||||
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
|
||||
|
||||
# at start-up, get current status and inform Settings
|
||||
Settings.handle_microsd_state_change(
|
||||
action=MicroSD.ACTION__INSERTED if self.is_inserted else MicroSD.ACTION__REMOVED
|
||||
)
|
||||
|
||||
if os.path.exists(self.FIFO_PATH):
|
||||
os.remove(self.FIFO_PATH)
|
||||
|
||||
os.mkfifo(self.FIFO_PATH, self.FIFO_MODE)
|
||||
|
||||
while self.keep_running:
|
||||
with open(self.FIFO_PATH) as fifo:
|
||||
action = fifo.read()
|
||||
print(f"fifo message: {action}")
|
||||
|
||||
Settings.handle_microsd_state_change(action=action)
|
||||
Controller.get_instance().activate_toast(SDCardStateChangeToastManagerThread(action=action))
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import embit
|
||||
from embit import bip32
|
||||
|
||||
from binascii import b2a_base64
|
||||
from hashlib import sha256
|
||||
|
||||
from embit import bip32, compact, ec
|
||||
from embit.bip32 import HDKey
|
||||
from embit.descriptor import Descriptor
|
||||
from embit.networks import NETWORKS
|
||||
from embit.networks import NETWORKS
|
||||
from embit.util import secp256k1
|
||||
|
||||
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
@@ -93,3 +98,101 @@ def get_multisig_address(descriptor: Descriptor, index: int = 0, is_change: bool
|
||||
elif descriptor.is_taproot:
|
||||
# TODO: Not yet implemented!
|
||||
raise Exception("Taproot verification not yet implemented!")
|
||||
|
||||
|
||||
|
||||
def get_embit_network_name(settings_name):
|
||||
""" Convert SeedSigner SettingsConstants for `network` to embit's NETWORK key """
|
||||
lookup = {
|
||||
SettingsConstants.MAINNET: "main",
|
||||
SettingsConstants.TESTNET: "test",
|
||||
SettingsConstants.REGTEST: "regtest",
|
||||
}
|
||||
return lookup.get(settings_name)
|
||||
|
||||
|
||||
|
||||
def parse_derivation_path(derivation_path: str) -> dict:
|
||||
"""
|
||||
Parses a derivation path into its related SettingsConstants equivalents.
|
||||
|
||||
Primarily only supports single sig derivation paths.
|
||||
|
||||
May return None for fields it cannot parse.
|
||||
"""
|
||||
# Support either m/44'/... or m/44h/... style
|
||||
derivation_path = derivation_path.replace("'", "h")
|
||||
|
||||
sections = derivation_path.split("/")
|
||||
|
||||
if sections[1] == "48h":
|
||||
# So far this helper is only meant for single sig message signing
|
||||
raise Exception("Not implemented")
|
||||
|
||||
lookups = {
|
||||
"script_types": {
|
||||
"84h": SettingsConstants.NATIVE_SEGWIT,
|
||||
"49h": SettingsConstants.NESTED_SEGWIT,
|
||||
"86h": SettingsConstants.TAPROOT,
|
||||
},
|
||||
"networks": {
|
||||
"0h": SettingsConstants.MAINNET,
|
||||
"1h": [SettingsConstants.TESTNET, SettingsConstants.REGTEST],
|
||||
}
|
||||
}
|
||||
|
||||
details = dict()
|
||||
details["script_type"] = lookups["script_types"].get(sections[1])
|
||||
if not details["script_type"]:
|
||||
details["script_type"] = SettingsConstants.CUSTOM_DERIVATION
|
||||
details["network"] = lookups["networks"].get(sections[2])
|
||||
|
||||
# Check if there's a standard change path
|
||||
if sections[-2] in ["0", "1"]:
|
||||
details["is_change"] = sections[-2] == "1"
|
||||
else:
|
||||
details["is_change"] = None
|
||||
|
||||
# Check if there's a standard address index
|
||||
if sections[-1].isdigit():
|
||||
details["index"] = int(sections[-1])
|
||||
else:
|
||||
details["index"] = None
|
||||
|
||||
if details["is_change"] is not None and details["index"] is not None:
|
||||
# standard change and addr index; safe to truncate to the wallet level
|
||||
details["wallet_derivation_path"] = "/".join(sections[:-2])
|
||||
else:
|
||||
details["wallet_derivation_path"] = None
|
||||
|
||||
details["clean_match"] = True
|
||||
for k, v in details.items():
|
||||
if v is None:
|
||||
# At least one field couldn't be parsed
|
||||
details["clean_match"] = False
|
||||
break
|
||||
|
||||
return details
|
||||
|
||||
|
||||
|
||||
def sign_message(seed_bytes: bytes, derivation: str, msg: bytes, compressed: bool = True, embit_network: str = "main") -> bytes:
|
||||
"""
|
||||
from: https://github.com/cryptoadvance/specter-diy/blob/b58a819ef09b2bca880a82c7e122618944355118/src/apps/signmessage/signmessage.py
|
||||
"""
|
||||
"""Sign message with private key"""
|
||||
msghash = sha256(
|
||||
sha256(
|
||||
b"\x18Bitcoin Signed Message:\n" + compact.to_bytes(len(msg)) + msg
|
||||
).digest()
|
||||
).digest()
|
||||
|
||||
root = bip32.HDKey.from_seed(seed_bytes, version=NETWORKS[embit_network]["xprv"])
|
||||
prv = root.derive(derivation).key
|
||||
sig = secp256k1.ecdsa_sign_recoverable(msghash, prv._secret)
|
||||
flag = sig[64]
|
||||
sig = ec.Signature(sig[:64])
|
||||
c = 4 if compressed else 0
|
||||
flag = bytes([27 + flag + c])
|
||||
ser = flag + secp256k1.ecdsa_signature_serialize_compact(sig._sig)
|
||||
return b2a_base64(ser).strip().decode()
|
||||
|
||||
@@ -5,7 +5,6 @@ from embit import bip39
|
||||
from embit.bip39 import mnemonic_to_bytes, mnemonic_from_bytes
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models.seed import Seed
|
||||
|
||||
|
||||
def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]:
|
||||
@@ -15,6 +14,7 @@ def calculate_checksum(mnemonic: list, wordlist_language_code: str) -> List[str]
|
||||
If 11- or 23-words are provided, append word `0000` to end of list as temp final
|
||||
word.
|
||||
"""
|
||||
from seedsigner.models.seed import Seed
|
||||
if len(mnemonic) in [11, 23]:
|
||||
mnemonic.append(Seed.get_wordlist(wordlist_language_code)[0])
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class Bytewords:
|
||||
elif style == Bytewords_Style_minimal:
|
||||
return encode_minimal(bytes)
|
||||
else:
|
||||
assert(False)
|
||||
assert False
|
||||
|
||||
@staticmethod
|
||||
def decode(style, str):
|
||||
@@ -137,4 +137,4 @@ class Bytewords:
|
||||
elif style == Bytewords_Style_minimal:
|
||||
return decode(str, 0, 2)
|
||||
else:
|
||||
assert(False)
|
||||
assert False
|
||||
|
||||
@@ -255,7 +255,7 @@ class FountainDecoder:
|
||||
elif self.is_failure():
|
||||
return 'Exception: {}'.format(self.result)
|
||||
else:
|
||||
assert(False)
|
||||
assert False
|
||||
|
||||
def print_part(self, p):
|
||||
print('part indexes: {}'.format(self.indexes_to_string(p.indexes)))
|
||||
|
||||
@@ -84,7 +84,7 @@ class Part:
|
||||
|
||||
class FountainEncoder:
|
||||
def __init__(self, message, max_fragment_len, first_seq_num = 0, min_fragment_len = 10):
|
||||
assert(len(message) <= MAX_UINT32)
|
||||
assert len(message) <= MAX_UINT32
|
||||
self.message_len = len(message)
|
||||
self.checksum = crc32_int(message)
|
||||
self.fragment_len = FountainEncoder.find_nominal_fragment_length(self.message_len, min_fragment_len, max_fragment_len)
|
||||
@@ -93,9 +93,9 @@ class FountainEncoder:
|
||||
|
||||
@staticmethod
|
||||
def find_nominal_fragment_length(message_len, min_fragment_len, max_fragment_len):
|
||||
assert(message_len > 0)
|
||||
assert(min_fragment_len > 0)
|
||||
assert(max_fragment_len >= min_fragment_len)
|
||||
assert message_len > 0
|
||||
assert min_fragment_len > 0
|
||||
assert max_fragment_len >= min_fragment_len
|
||||
max_fragment_count = message_len // min_fragment_len
|
||||
fragment_len = None
|
||||
|
||||
@@ -104,7 +104,7 @@ class FountainEncoder:
|
||||
if fragment_len <= max_fragment_len:
|
||||
break
|
||||
|
||||
assert(fragment_len != None)
|
||||
assert fragment_len != None
|
||||
return fragment_len
|
||||
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ class RandomSampler:
|
||||
|
||||
def __init__(self, probs):
|
||||
for p in probs:
|
||||
assert(p > 0)
|
||||
assert p > 0
|
||||
|
||||
# Normalize given probabilities
|
||||
total = sum(probs)
|
||||
assert(total > 0)
|
||||
assert total > 0
|
||||
|
||||
n = len(probs)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ def join_bytes(list_of_ba):
|
||||
|
||||
def xor_into(target, source):
|
||||
count = len(target)
|
||||
assert(count == len(source)) # Must be the same length
|
||||
assert count == len(source) # Must be the same length
|
||||
for i in range(count):
|
||||
target[i] ^= source[i]
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Must import crucial base class first!
|
||||
from .singleton import Singleton, ConfigurableSingleton
|
||||
|
||||
from .seed import *
|
||||
from .qr_type import *
|
||||
from .decode_qr import *
|
||||
from .encode_qr import *
|
||||
from .psbt_parser import *
|
||||
from .seed_storage import *
|
||||
from .settings import *
|
||||
|
||||
@@ -9,14 +9,13 @@ from embit import psbt, bip39
|
||||
from pyzbar import pyzbar
|
||||
from pyzbar.pyzbar import ZBarSymbol
|
||||
from urtypes.crypto import PSBT as UR_PSBT
|
||||
from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP
|
||||
from urtypes.crypto import Account, Output
|
||||
from urtypes.bytes import Bytes
|
||||
|
||||
from seedsigner.helpers.ur2.ur_decoder import URDecoder
|
||||
from seedsigner.models.psbt_parser import PSBTParser
|
||||
|
||||
from . import QRType, Seed
|
||||
from .settings import SettingsConstants
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -84,6 +83,9 @@ class DecodeQR:
|
||||
elif self.qr_type == QRType.BITCOIN_ADDRESS:
|
||||
self.decoder = BitcoinAddressQrDecoder() # Single Segment bitcoin address
|
||||
|
||||
elif self.qr_type == QRType.SIGN_MESSAGE:
|
||||
self.decoder = SignMessageQrDecoder() # Single Segment sign message request
|
||||
|
||||
elif self.qr_type == QRType.WALLET__SPECTER:
|
||||
self.decoder = SpecterWalletQrDecoder() # Specter Desktop Wallet Export decoder
|
||||
|
||||
@@ -132,6 +134,9 @@ class DecodeQR:
|
||||
return rt
|
||||
|
||||
|
||||
# TODO: Refactor all of these specific `get_` to just something generic like
|
||||
# `get_data` and let each QRDecoder class return whatever it needs to as a
|
||||
# str, tuple, dict, etc?
|
||||
def get_psbt(self):
|
||||
if self.complete:
|
||||
data = self.get_data_psbt()
|
||||
@@ -175,12 +180,7 @@ class DecodeQR:
|
||||
|
||||
def get_settings_data(self):
|
||||
if self.is_settings:
|
||||
return self.decoder.settings
|
||||
|
||||
|
||||
def get_settings_config_name(self):
|
||||
if self.is_settings:
|
||||
return self.decoder.config_name
|
||||
return self.decoder.data
|
||||
|
||||
|
||||
def get_address(self):
|
||||
@@ -193,6 +193,15 @@ class DecodeQR:
|
||||
return self.decoder.get_address_type()
|
||||
|
||||
|
||||
def get_qr_data(self) -> dict:
|
||||
"""
|
||||
This provides a single access point for external code to retrieve the QR data,
|
||||
regardless of which decoder is actually instantiated.
|
||||
"""
|
||||
# TODO: Implement this approach across all decoders
|
||||
return self.decoder.get_qr_data()
|
||||
|
||||
|
||||
def get_wallet_descriptor(self):
|
||||
if self.is_wallet_descriptor:
|
||||
if self.qr_type in [QRType.OUTPUT__UR, QRType.ACCOUNT__UR, QRType.BYTES__UR]:
|
||||
@@ -273,6 +282,11 @@ class DecodeQR:
|
||||
return self.qr_type == QRType.BITCOIN_ADDRESS
|
||||
|
||||
|
||||
@property
|
||||
def is_sign_message(self):
|
||||
return self.qr_type == QRType.SIGN_MESSAGE
|
||||
|
||||
|
||||
@property
|
||||
def is_wallet_descriptor(self):
|
||||
check = self.qr_type in [QRType.WALLET__SPECTER, QRType.WALLET__UR, QRType.WALLET__CONFIGFILE, QRType.WALLET__GENERIC, QRType.OUTPUT__UR]
|
||||
@@ -363,8 +377,12 @@ class DecodeQR:
|
||||
elif DecodeQR.is_bitcoin_address(s):
|
||||
return QRType.BITCOIN_ADDRESS
|
||||
|
||||
# message signing
|
||||
elif DecodeQR.is_sign_message(s):
|
||||
return QRType.SIGN_MESSAGE
|
||||
|
||||
# config data
|
||||
if "type=settings" in s:
|
||||
if s.startswith("settings::"):
|
||||
return QRType.SETTINGS
|
||||
|
||||
# Seed
|
||||
@@ -482,7 +500,13 @@ class DecodeQR:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def is_sign_message(s):
|
||||
return type(s) == str and s.startswith("signmessage")
|
||||
|
||||
|
||||
@staticmethod
|
||||
def multisig_setup_file_to_descriptor(text) -> str:
|
||||
# sample text file, parse the contents and create descriptor
|
||||
@@ -578,6 +602,8 @@ class DecodeQR:
|
||||
|
||||
return descriptor
|
||||
|
||||
|
||||
|
||||
class BaseQrDecoder:
|
||||
def __init__(self):
|
||||
self.total_segments = None
|
||||
@@ -590,6 +616,10 @@ class BaseQrDecoder:
|
||||
|
||||
def add(self, segment, qr_type):
|
||||
raise Exception("Not implemented in child class")
|
||||
|
||||
def get_qr_data(self) -> dict:
|
||||
# TODO: standardize this approach across all decoders (example: SignMessageQrDecoder)
|
||||
raise Exception("get_qr_data must be implemented in decoder child class")
|
||||
|
||||
|
||||
|
||||
@@ -673,6 +703,8 @@ class SpecterPsbtQrDecoder(BaseAnimatedQrDecoder):
|
||||
def parse_segment(self, segment) -> str:
|
||||
return segment.split(" ")[-1].strip()
|
||||
|
||||
|
||||
|
||||
class Base64PsbtQrDecoder(BaseSingleFrameQrDecoder):
|
||||
"""
|
||||
Decodes single frame base64 encoded qr image.
|
||||
@@ -826,107 +858,68 @@ class SeedQrDecoder(BaseSingleFrameQrDecoder):
|
||||
|
||||
|
||||
|
||||
# TODO: Refactor this to work with the new SettingsDefinition
|
||||
class SettingsQrDecoder(BaseSingleFrameQrDecoder):
|
||||
"""
|
||||
Decodes settings data from the SettingsQR Generator.
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = {}
|
||||
self.config_name = None
|
||||
self.data = None
|
||||
|
||||
|
||||
def add(self, segment, qr_type=QRType.SETTINGS):
|
||||
# print(f"SettingsQR:\n{segment}")
|
||||
try:
|
||||
self.settings = {}
|
||||
"""
|
||||
* Ignores unrecognized settings options.
|
||||
* Raises an Exception if a settings value is invalid.
|
||||
|
||||
# QR Settings format is space-separated key/value pairs, but should also
|
||||
# parse \n-separated keys.
|
||||
for entry in segment.split():
|
||||
key = entry.split("=")[0].strip()
|
||||
value = entry.split("=")[1].strip()
|
||||
self.settings[key] = value
|
||||
See `Settings.update()` for info on settings validation, especially for
|
||||
missing settings.
|
||||
"""
|
||||
if not segment.startswith("settings::"):
|
||||
raise Exception("Invalid SettingsQR data")
|
||||
|
||||
# Leave any other parsing or validation up to the Settings class itself.
|
||||
# SettingsQR are just ascii data to hand it over as-is.
|
||||
self.data = segment
|
||||
|
||||
# Remove values only needed for import
|
||||
self.settings.pop("type", None)
|
||||
version = self.settings.pop("version", None)
|
||||
if not version or int(version) != 1:
|
||||
raise Exception(f"Settings QR version {version} not supported")
|
||||
self.complete = True
|
||||
self.collected_segments = 1
|
||||
return DecodeQRStatus.COMPLETE
|
||||
|
||||
self.config_name = self.settings.pop("name", None)
|
||||
if self.config_name:
|
||||
self.config_name = self.config_name.replace("_", " ")
|
||||
|
||||
# Have to translate the abbreviated settings into the human-readable values
|
||||
# used in the normal Settings.
|
||||
map_abbreviated_enable = {
|
||||
"0": SettingsConstants.OPTION__DISABLED,
|
||||
"1": SettingsConstants.OPTION__ENABLED,
|
||||
"2": SettingsConstants.OPTION__PROMPT,
|
||||
}
|
||||
map_abbreviated_sig_types = {
|
||||
"s": SettingsConstants.SINGLE_SIG,
|
||||
"m": SettingsConstants.MULTISIG,
|
||||
}
|
||||
map_abbreviated_scripts = {
|
||||
"na": SettingsConstants.NATIVE_SEGWIT,
|
||||
"ne": SettingsConstants.NESTED_SEGWIT,
|
||||
"tr": SettingsConstants.TAPROOT,
|
||||
"cu": SettingsConstants.CUSTOM_DERIVATION,
|
||||
}
|
||||
map_abbreviated_coordinators = {
|
||||
"bw": SettingsConstants.COORDINATOR__BLUE_WALLET,
|
||||
"sw": SettingsConstants.COORDINATOR__SPARROW,
|
||||
"sd": SettingsConstants.COORDINATOR__SPECTER_DESKTOP,
|
||||
}
|
||||
|
||||
def convert_abbreviated_value(category, key, abbreviation_map, is_list=False, new_key_name=None):
|
||||
try:
|
||||
if key not in self.settings:
|
||||
print(f"'{key}' not found in settings")
|
||||
return
|
||||
value = self.settings[key]
|
||||
|
||||
if not is_list:
|
||||
new_value = abbreviation_map.get(value)
|
||||
if not new_value:
|
||||
logger.error(f"No abbreviation map value for \"{value}\" for setting {key}")
|
||||
return
|
||||
else:
|
||||
# `value` is a comma-separated list; yields list of map matches
|
||||
values = value.split(",")
|
||||
new_value = []
|
||||
for v in values:
|
||||
mapped_value = abbreviation_map.get(v)
|
||||
if not mapped_value:
|
||||
logger.error(f"No abbreviation map value for \"{v}\" for setting {key}")
|
||||
return
|
||||
new_value.append(mapped_value)
|
||||
del self.settings[key]
|
||||
if new_key_name:
|
||||
key = new_key_name
|
||||
if category not in self.settings:
|
||||
self.settings[category] = {}
|
||||
self.settings[category][key] = new_value
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return
|
||||
class SignMessageQrDecoder(BaseSingleFrameQrDecoder):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.message = None
|
||||
self.derivation_path = None
|
||||
|
||||
convert_abbreviated_value("wallet", "coord", map_abbreviated_coordinators, is_list=True, new_key_name="coordinators")
|
||||
convert_abbreviated_value("features", "xpub", map_abbreviated_enable, new_key_name="xpub_export")
|
||||
convert_abbreviated_value("features", "sigs", map_abbreviated_sig_types, is_list=True, new_key_name="sig_types")
|
||||
convert_abbreviated_value("features", "scripts", map_abbreviated_scripts, is_list=True, new_key_name="script_types")
|
||||
convert_abbreviated_value("features", "xp_det", map_abbreviated_enable, new_key_name="show_xpub_details")
|
||||
convert_abbreviated_value("features", "passphrase", map_abbreviated_enable)
|
||||
convert_abbreviated_value("features", "priv_warn", map_abbreviated_enable, new_key_name="show_privacy_warnings")
|
||||
convert_abbreviated_value("features", "dire_warn", map_abbreviated_enable, new_key_name="show_dire_warnings")
|
||||
|
||||
self.complete = True
|
||||
self.collected_segments = 1
|
||||
return DecodeQRStatus.COMPLETE
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
def add(self, segment, qr_type=QRType.SIGN_MESSAGE):
|
||||
"""
|
||||
Expected QR data format:
|
||||
|
||||
signmessage {derivation_path} ascii:{message}
|
||||
"""
|
||||
parts = segment.split()
|
||||
self.derivation_path = parts[1].replace("h", "'")
|
||||
fmt = parts[2].split(":")[0]
|
||||
self.message = segment.split(f"{fmt}:")[1]
|
||||
|
||||
# TODO: support formats other than ascii?
|
||||
if fmt != "ascii":
|
||||
print(f"Sign message: Unsupported format: {fmt}")
|
||||
return DecodeQRStatus.INVALID
|
||||
|
||||
self.complete = True
|
||||
self.collected_segments = 1
|
||||
|
||||
return DecodeQRStatus.COMPLETE
|
||||
|
||||
|
||||
def get_qr_data(self) -> dict:
|
||||
return dict(derivation_path=self.derivation_path, message=self.message)
|
||||
|
||||
|
||||
|
||||
class BitcoinAddressQrDecoder(BaseSingleFrameQrDecoder):
|
||||
@@ -1091,11 +1084,10 @@ class GenericWalletQrDecoder(BaseSingleFrameQrDecoder):
|
||||
|
||||
def get_wallet_descriptor(self):
|
||||
return self.descriptor
|
||||
|
||||
class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder):
|
||||
|
||||
|
||||
|
||||
|
||||
class MultiSigConfigFileQRDecoder(GenericWalletQrDecoder):
|
||||
def add(self, segment, qr_type=QRType.WALLET__CONFIGFILE):
|
||||
descriptor = DecodeQR.multisig_setup_file_to_descriptor(segment)
|
||||
return super().add(descriptor,qr_type=QRType.WALLET__CONFIGFILE)
|
||||
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ from embit.psbt import PSBT
|
||||
from seedsigner.helpers.ur2.ur_encoder import UREncoder
|
||||
from seedsigner.helpers.ur2.ur import UR
|
||||
from seedsigner.helpers.qr import QR
|
||||
from seedsigner.models import Seed, QRType
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
from urtypes.crypto import PSBT as UR_PSBT
|
||||
from urtypes.crypto import Account, HDKey, Output, Keypath, PathComponent, SCRIPT_EXPRESSION_TAG_MAP
|
||||
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ class EncodeQR:
|
||||
qr_density: str = SettingsConstants.DENSITY__MEDIUM
|
||||
wordlist_language_code: str = SettingsConstants.WORDLIST_LANGUAGE__ENGLISH
|
||||
bitcoin_address: str = None
|
||||
signed_message: str = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.qr = QR()
|
||||
@@ -97,9 +99,13 @@ class EncodeQR:
|
||||
self.encoder = CompactSeedQrEncoder(seed_phrase=self.seed_phrase,
|
||||
wordlist_language_code=self.wordlist_language_code)
|
||||
|
||||
# Misc formats
|
||||
elif self.qr_type == QRType.BITCOIN_ADDRESS:
|
||||
self.encoder = BitcoinAddressEncoder(address=self.bitcoin_address)
|
||||
|
||||
elif self.qr_type == QRType.SIGN_MESSAGE:
|
||||
self.encoder = SignedMessageEncoder(signed_message=self.signed_message)
|
||||
|
||||
else:
|
||||
raise Exception('QR Type not supported')
|
||||
|
||||
@@ -154,6 +160,17 @@ class BaseQrEncoder:
|
||||
|
||||
|
||||
|
||||
class BaseStaticQrEncoder(BaseQrEncoder):
|
||||
def seq_len(self):
|
||||
return 1
|
||||
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class BasePsbtQrEncoder(BaseQrEncoder):
|
||||
def __init__(self, psbt: PSBT):
|
||||
self.psbt = psbt
|
||||
@@ -263,7 +280,7 @@ class SpecterPsbtQrEncoder(BasePsbtQrEncoder):
|
||||
|
||||
|
||||
|
||||
class SeedQrEncoder(BaseQrEncoder):
|
||||
class SeedQrEncoder(BaseStaticQrEncoder):
|
||||
def __init__(self, seed_phrase: List[str], wordlist_language_code: str):
|
||||
super().__init__()
|
||||
self.seed_phrase = seed_phrase
|
||||
@@ -273,10 +290,6 @@ class SeedQrEncoder(BaseQrEncoder):
|
||||
raise Exception('Wordlist Required')
|
||||
|
||||
|
||||
def seq_len(self):
|
||||
return 1
|
||||
|
||||
|
||||
def next_part(self):
|
||||
data = ""
|
||||
# Output as Numeric data format
|
||||
@@ -286,11 +299,6 @@ class SeedQrEncoder(BaseQrEncoder):
|
||||
return data
|
||||
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
return True
|
||||
|
||||
|
||||
|
||||
class CompactSeedQrEncoder(SeedQrEncoder):
|
||||
def next_part(self):
|
||||
@@ -322,23 +330,29 @@ class CompactSeedQrEncoder(SeedQrEncoder):
|
||||
|
||||
|
||||
|
||||
class BitcoinAddressEncoder(BaseQrEncoder):
|
||||
class BitcoinAddressEncoder(BaseStaticQrEncoder):
|
||||
def __init__(self, address: str):
|
||||
super().__init__()
|
||||
self.address = address
|
||||
|
||||
|
||||
def seq_len(self):
|
||||
return 1
|
||||
|
||||
|
||||
def next_part(self):
|
||||
return self.address
|
||||
|
||||
|
||||
@property
|
||||
def is_complete(self):
|
||||
return True
|
||||
|
||||
class SignedMessageEncoder(BaseStaticQrEncoder):
|
||||
"""
|
||||
Assumes that a signed message will fit in a single-frame QR
|
||||
"""
|
||||
def __init__(self, signed_message: str):
|
||||
super().__init__()
|
||||
self.signed_message = signed_message
|
||||
|
||||
|
||||
def next_part(self):
|
||||
return self.signed_message
|
||||
|
||||
|
||||
|
||||
class XpubQrEncoder(BaseQrEncoder):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from binascii import hexlify
|
||||
from embit import psbt, script, ec, bip32, bip39
|
||||
from embit import psbt, script, ec, bip32
|
||||
from embit.descriptor import Descriptor
|
||||
from embit.networks import NETWORKS
|
||||
from embit.psbt import PSBT
|
||||
from io import BytesIO
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models import Seed
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ class QRType:
|
||||
|
||||
BITCOIN_ADDRESS = "bitcoin_address"
|
||||
|
||||
SIGN_MESSAGE = "sign_message"
|
||||
|
||||
WALLET__SPECTER = "wallet__specter"
|
||||
WALLET__UR = "wallet__ur"
|
||||
WALLET__CONFIGFILE = "wallet__configfile"
|
||||
|
||||
@@ -6,7 +6,6 @@ from embit.networks import NETWORKS
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
from seedsigner.helpers import embit_utils
|
||||
|
||||
|
||||
class InvalidSeedException(Exception):
|
||||
@@ -113,6 +112,8 @@ class Seed:
|
||||
|
||||
|
||||
def get_xpub(self, wallet_path: str = '/', network: str = SettingsConstants.MAINNET):
|
||||
# Import here to avoid slow startup times; takes 1.35s to import the first time
|
||||
from seedsigner.helpers import embit_utils
|
||||
return embit_utils.get_xpub(seed_bytes=self.seed_bytes, derivation_path=wallet_path, embit_network=SettingsConstants.map_network_to_embit(network))
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from typing import List
|
||||
from seedsigner.models import Seed
|
||||
from seedsigner.models.seed import InvalidSeedException
|
||||
from seedsigner.models.seed import Seed, InvalidSeedException
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
|
||||
@@ -64,6 +63,11 @@ class SeedStorage:
|
||||
|
||||
|
||||
def update_pending_mnemonic(self, word: str, index: int):
|
||||
"""
|
||||
Replaces the nth word in the pending mnemonic.
|
||||
|
||||
* may specify a negative `index` (e.g. -1 is the last word).
|
||||
"""
|
||||
if index >= len(self._pending_mnemonic):
|
||||
raise Exception(f"index {index} is too high")
|
||||
self._pending_mnemonic[index] = word
|
||||
|
||||
@@ -4,12 +4,15 @@ import os
|
||||
import pathlib
|
||||
import platform
|
||||
|
||||
from typing import Any, List
|
||||
from typing import List
|
||||
|
||||
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
|
||||
from .singleton import Singleton
|
||||
from seedsigner.models.singleton import Singleton
|
||||
|
||||
|
||||
class InvalidSettingsQRData(Exception):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class Settings(Singleton):
|
||||
@@ -30,7 +33,7 @@ class Settings(Singleton):
|
||||
# Read persistent settings file, if it exists
|
||||
if os.path.exists(Settings.SETTINGS_FILENAME):
|
||||
with open(Settings.SETTINGS_FILENAME) as settings_file:
|
||||
settings.update(json.load(settings_file), disable_missing_entries=False)
|
||||
settings.update(json.load(settings_file))
|
||||
|
||||
# Setup Babel
|
||||
path = os.path.join(
|
||||
@@ -47,6 +50,70 @@ class Settings(Singleton):
|
||||
return cls._instance
|
||||
|
||||
|
||||
@classmethod
|
||||
def parse_settingsqr(cls, data: str) -> tuple[str, dict]:
|
||||
"""
|
||||
Parses SettingsQR data and returns a tuple of (config_name, settings_dict).
|
||||
|
||||
The resulting settings config can be applied by calling `Settings.update(settings_dict)`.
|
||||
"""
|
||||
if not data.startswith("settings::"):
|
||||
raise InvalidSettingsQRData()
|
||||
|
||||
version = data.split()[0].split("::")[1]
|
||||
if version != "v1":
|
||||
raise InvalidSettingsQRData(f"Unsupported SettingsQR version: {version}")
|
||||
|
||||
# Start parsing key/value settings at the nth split() index
|
||||
split_index = 1
|
||||
|
||||
# handle optional "name" attr
|
||||
config_name = None
|
||||
if "name=" in data.split()[1]:
|
||||
config_name = data.split("name=")[1].split()[0].replace("_", " ")
|
||||
split_index += 1
|
||||
|
||||
updated_settings = {}
|
||||
for entry in data.split()[split_index:]:
|
||||
abbreviated_name, value = entry.split("=")
|
||||
|
||||
# Parse multi-value settings; integer-ize where needed
|
||||
if "," in value:
|
||||
values_updated = []
|
||||
for v in value.split(","):
|
||||
if v.isdigit():
|
||||
v = int(v)
|
||||
values_updated.append(v)
|
||||
value = values_updated
|
||||
elif value.isdigit():
|
||||
value = int(value)
|
||||
|
||||
# Replace abbreviated name with full attr_name
|
||||
settings_entry = SettingsDefinition.get_settings_entry_by_abbreviated_name(abbreviated_name)
|
||||
if not settings_entry:
|
||||
print(f"Ignoring unrecognized attribute: {abbreviated_name}")
|
||||
continue
|
||||
|
||||
# Validate value(s) against SettingsDefinition's valid options
|
||||
if type(value) is not list:
|
||||
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:
|
||||
# Special case: trying to enable Persistent Settings when
|
||||
# DISABLED is the only option allowed (because the SD card is not
|
||||
# inserted. Explicitly set to DISABLED.
|
||||
value = SettingsConstants.OPTION__DISABLED
|
||||
break
|
||||
raise InvalidSettingsQRData(f"""{abbreviated_name} = '{v}' is not valid""")
|
||||
|
||||
updated_settings[settings_entry.attr_name] = value
|
||||
|
||||
return (config_name, updated_settings)
|
||||
|
||||
|
||||
def __str__(self):
|
||||
return json.dumps(self._data, indent=4)
|
||||
|
||||
@@ -61,32 +128,22 @@ class Settings(Singleton):
|
||||
os.fsync(settings_file.fileno())
|
||||
|
||||
|
||||
def update(self, new_settings: dict, disable_missing_entries: bool = True):
|
||||
def update(self, new_settings: dict):
|
||||
"""
|
||||
* disable_missing_entries: The SettingsQR Generator omits any multiselect
|
||||
fields with zero selections or disabled Enabled/Disabled toggles. So if a
|
||||
field is missing, interpret it as such. But if this is set to False, keep
|
||||
the existing value for the field; most likely this is a new setting that
|
||||
the user may not have a value for when loading their persistent settings,
|
||||
in which case this would preserve the new field's default value.
|
||||
Replaces the current settings with the incoming dict.
|
||||
|
||||
If a setting is missing from `new_settings`:
|
||||
* Hidden settings that have a value remain as-is.
|
||||
* All other missing settings are set to their default value.
|
||||
"""
|
||||
for entry in SettingsDefinition.settings_entries:
|
||||
if entry.attr_name not in new_settings:
|
||||
if not disable_missing_entries:
|
||||
if entry.visibility == SettingsConstants.VISIBILITY__HIDDEN and entry.attr_name in self._data:
|
||||
# Preserve existing hidden values
|
||||
new_settings[entry.attr_name] = self._data[entry.attr_name]
|
||||
else:
|
||||
# Setting is missing; insert default
|
||||
new_settings[entry.attr_name] = entry.default_value
|
||||
|
||||
elif entry.visibility == SettingsConstants.VISIBILITY__HIDDEN:
|
||||
# Missing hidden values always get their default
|
||||
new_settings[entry.attr_name] = entry.default_value
|
||||
|
||||
elif entry.type == SettingsConstants.TYPE__MULTISELECT:
|
||||
# Clear out the multiselect
|
||||
new_settings[entry.attr_name] = []
|
||||
|
||||
elif entry.type in SettingsConstants.ALL_ENABLED_DISABLED_TYPES:
|
||||
# Set DISABLED for this missing setting
|
||||
new_settings[entry.attr_name] = SettingsConstants.OPTION__DISABLED
|
||||
|
||||
else:
|
||||
# Clean the incoming data, if necessary
|
||||
@@ -94,9 +151,6 @@ class Settings(Singleton):
|
||||
if type(new_settings[entry.attr_name]) == str:
|
||||
# Break comma-separated SettingsQR input into List
|
||||
new_settings[entry.attr_name] = new_settings[entry.attr_name].split(",")
|
||||
|
||||
# TODO: If value is not in entry.selection_options...
|
||||
|
||||
|
||||
for key, value in new_settings.items():
|
||||
self.set_value(key, value)
|
||||
@@ -204,28 +258,39 @@ class Settings(Singleton):
|
||||
return self._data[SettingsConstants.SETTING__DEBUG] == SettingsConstants.OPTION__ENABLED
|
||||
|
||||
|
||||
def microsd_handler(action):
|
||||
def handle_microsd_state_change(action: str):
|
||||
"""
|
||||
Enables/Disables the Persistent Settings option based on the MicroSD card state.
|
||||
"""
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
|
||||
|
||||
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
|
||||
|
||||
if action == MicroSD.ACTION__INSERTED:
|
||||
# restore persistent settings back to defaults
|
||||
# SD card was just inserted.
|
||||
# Restore persistent settings back to defaults
|
||||
entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
|
||||
entry.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED
|
||||
entry.help_text = _("Store Settings on SD card.")
|
||||
|
||||
entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT
|
||||
|
||||
# TODO: Perhaps prompt the user if the current settings (not including persistent
|
||||
# settings) should overwrite the settings on disk, if they differ:
|
||||
# - Overwrite settings on the SD?
|
||||
# - Load settings from SD?
|
||||
# if Settings file exists (meaning persistent settings was previously enabled), write out current settings to disk
|
||||
if os.path.exists(Settings.SETTINGS_FILENAME):
|
||||
# enable persistent settings first, then save
|
||||
Settings.get_instance()._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] = SettingsConstants.OPTION__ENABLED
|
||||
Settings.get_instance().save()
|
||||
|
||||
|
||||
elif action == MicroSD.ACTION__REMOVED:
|
||||
# set persistent settings to disabled value directly
|
||||
# SD card was just removed.
|
||||
# Set persistent settings to disabled value directly
|
||||
Settings.get_instance()._data[SettingsConstants.SETTING__PERSISTENT_SETTINGS] = SettingsConstants.OPTION__DISABLED
|
||||
|
||||
|
||||
# set persistent settings to only have disabled as an option, adding additional help text that microSD is removed
|
||||
entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS)
|
||||
entry.selection_options = SettingsConstants.OPTIONS__ONLY_DISABLED
|
||||
entry.help_text = _("MicroSD card is removed")
|
||||
entry.help_text = SettingsConstants.PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT
|
||||
|
||||
else:
|
||||
raise Exception(f"Invalid MicroSD action: {action}")
|
||||
|
||||
@@ -128,6 +128,8 @@ class SettingsConstants:
|
||||
if network == SettingsConstants.REGTEST:
|
||||
return "regtest"
|
||||
|
||||
PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT = _("Store Settings on SD card")
|
||||
PERSISTENT_SETTINGS__SD_REMOVED__HELP_TEXT = _("Insert SD card to enable")
|
||||
|
||||
SINGLE_SIG = "ss"
|
||||
MULTISIG = "ms"
|
||||
@@ -185,9 +187,10 @@ class SettingsConstants:
|
||||
SETTING__CAMERA_ROTATION = "camera_rotation"
|
||||
SETTING__COMPACT_SEEDQR = "compact_seedqr"
|
||||
SETTING__BIP85_CHILD_SEEDS = "bip85_child_seeds"
|
||||
SETTING__MESSAGE_SIGNING = "message_signing"
|
||||
SETTING__PRIVACY_WARNINGS = "privacy_warnings"
|
||||
SETTING__DIRE_WARNINGS = "dire_warnings"
|
||||
SETTING__PASSPHRASE_WARNING = "passphrase_warning"
|
||||
SETTING__QR_BRIGHTNESS_TIPS = "qr_brightness_tips"
|
||||
SETTING__PARTNER_LOGOS = "partner_logos"
|
||||
|
||||
SETTING__DEBUG = "debug"
|
||||
@@ -240,12 +243,11 @@ class SettingsEntry:
|
||||
category: str
|
||||
attr_name: str
|
||||
display_name: str
|
||||
verbose_name: str = None
|
||||
abbreviated_name: str = None
|
||||
visibility: str = SettingsConstants.VISIBILITY__GENERAL
|
||||
type: str = SettingsConstants.TYPE__ENABLED_DISABLED
|
||||
help_text: str = None
|
||||
selection_options: List[str] = None
|
||||
selection_options: list[tuple[str | int], str] = None
|
||||
default_value: Any = None
|
||||
|
||||
def __post_init__(self):
|
||||
@@ -256,7 +258,7 @@ class SettingsEntry:
|
||||
self.selection_options = SettingsConstants.OPTIONS__ENABLED_DISABLED_PROMPT
|
||||
|
||||
elif self.type == SettingsConstants.TYPE__ENABLED_DISABLED_PROMPT_REQUIRED:
|
||||
self.selection_options = [SettingsConstants.ALL_OPTIONS]
|
||||
self.selection_options = SettingsConstants.ALL_OPTIONS
|
||||
|
||||
# Account for List[tuple] and tuple formats as default_value
|
||||
if type(self.default_value) == list and type(self.default_value[0]) == tuple:
|
||||
@@ -275,18 +277,12 @@ class SettingsEntry:
|
||||
|
||||
|
||||
def get_selection_option_value(self, i: int):
|
||||
""" Returns the value of the selection option at index `i` """
|
||||
value = self.selection_options[i]
|
||||
if type(value) == tuple:
|
||||
value = value[0]
|
||||
return value
|
||||
|
||||
|
||||
def get_selection_option_display_name(self, i: int) -> str:
|
||||
value = self.selection_options[i]
|
||||
if type(value) == tuple:
|
||||
value = value[1]
|
||||
return value
|
||||
|
||||
|
||||
def get_selection_option_display_name_by_value(self, value) -> str:
|
||||
for option in self.selection_options:
|
||||
@@ -332,9 +328,8 @@ class SettingsEntry:
|
||||
return {
|
||||
"category": self.category,
|
||||
"attr_name": self.attr_name,
|
||||
"display_name": self.display_name,
|
||||
"verbose_name": self.verbose_name,
|
||||
"abbreviated_name": self.abbreviated_name,
|
||||
"display_name": self.display_name,
|
||||
"visibility": self.visibility,
|
||||
"type": self.type,
|
||||
"help_text": self.help_text,
|
||||
@@ -367,6 +362,7 @@ class SettingsDefinition:
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__LOCALE,
|
||||
display_name=_("Language"),
|
||||
abbreviated_name="lang",
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
selection_options=SettingsConstants.ALL_LOCALES,
|
||||
default_value=SettingsConstants.LOCALE__ENGLISH),
|
||||
@@ -374,6 +370,7 @@ class SettingsDefinition:
|
||||
# TODO: Support other bip-39 wordlist languages! Until then, type == HIDDEN
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__WORDLIST_LANGUAGE,
|
||||
abbreviated_name="wordlist_lang",
|
||||
display_name=_("Mnemonic language"),
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__HIDDEN,
|
||||
@@ -382,12 +379,14 @@ class SettingsDefinition:
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__PERSISTENT_SETTINGS,
|
||||
abbreviated_name="persistent",
|
||||
display_name=_("Persistent settings"),
|
||||
help_text=_("Store Settings on SD card."),
|
||||
help_text=SettingsConstants.PERSISTENT_SETTINGS__SD_INSERTED__HELP_TEXT,
|
||||
default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__WALLET,
|
||||
attr_name=SettingsConstants.SETTING__COORDINATORS,
|
||||
abbreviated_name="coords",
|
||||
display_name=_("Coordinator software"),
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
selection_options=SettingsConstants.ALL_COORDINATORS,
|
||||
@@ -400,6 +399,7 @@ class SettingsDefinition:
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__BTC_DENOMINATION,
|
||||
abbreviated_name="denom",
|
||||
display_name=_("Denomination display"),
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
selection_options=SettingsConstants.ALL_BTC_DENOMINATIONS,
|
||||
@@ -431,6 +431,7 @@ class SettingsDefinition:
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__SIG_TYPES,
|
||||
abbreviated_name="sigs",
|
||||
display_name=_("Sig types"),
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
@@ -439,6 +440,7 @@ class SettingsDefinition:
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__SCRIPT_TYPES,
|
||||
abbreviated_name="scripts",
|
||||
display_name=_("Script types"),
|
||||
type=SettingsConstants.TYPE__MULTISELECT,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
@@ -461,11 +463,12 @@ class SettingsDefinition:
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__CAMERA_ROTATION,
|
||||
abbreviated_name="camera",
|
||||
display_name=_("Camera rotation"),
|
||||
type=SettingsConstants.TYPE__SELECT_1,
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS,
|
||||
default_value=SettingsConstants.CAMERA_ROTATION__0),
|
||||
default_value=SettingsConstants.CAMERA_ROTATION__180),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
|
||||
@@ -475,30 +478,40 @@ class SettingsDefinition:
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__BIP85_CHILD_SEEDS,
|
||||
abbreviated_name="bip85",
|
||||
display_name=_("BIP-85 child seeds"),
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__MESSAGE_SIGNING,
|
||||
display_name="Message signing",
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__DISABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PRIVACY_WARNINGS,
|
||||
abbreviated_name="priv_warn",
|
||||
display_name=_("Show privacy warnings"),
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__DIRE_WARNINGS,
|
||||
abbreviated_name="dire_warn",
|
||||
display_name=_("Show dire warnings"),
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PASSPHRASE_WARNING,
|
||||
display_name=_("Show passphrase warning"),
|
||||
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS_TIPS,
|
||||
display_name=_("Show QR brightness tips"),
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES,
|
||||
attr_name=SettingsConstants.SETTING__PARTNER_LOGOS,
|
||||
abbreviated_name="partners",
|
||||
display_name=_("Show partner logos"),
|
||||
visibility=SettingsConstants.VISIBILITY__ADVANCED,
|
||||
default_value=SettingsConstants.OPTION__ENABLED),
|
||||
@@ -514,10 +527,11 @@ class SettingsDefinition:
|
||||
# "Hidden" settings with no UI interaction
|
||||
SettingsEntry(category=SettingsConstants.CATEGORY__SYSTEM,
|
||||
attr_name=SettingsConstants.SETTING__QR_BRIGHTNESS,
|
||||
abbreviated_name="qr_brightness",
|
||||
display_name=_("QR background color"),
|
||||
type=SettingsConstants.TYPE__FREE_ENTRY,
|
||||
visibility=SettingsConstants.VISIBILITY__HIDDEN,
|
||||
default_value=189),
|
||||
default_value=62),
|
||||
]
|
||||
|
||||
|
||||
@@ -537,6 +551,13 @@ class SettingsDefinition:
|
||||
return entry
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_settings_entry_by_abbreviated_name(cls, abbreviated_name: str) -> SettingsEntry:
|
||||
for entry in cls.settings_entries:
|
||||
if abbreviated_name in [entry.abbreviated_name, entry.attr_name]:
|
||||
return entry
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_defaults(cls) -> dict:
|
||||
as_dict = {}
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
import logging
|
||||
from gettext import gettext as _
|
||||
from typing import List
|
||||
|
||||
from embit.psbt import PSBT
|
||||
from embit import script
|
||||
from embit.networks import NETWORKS
|
||||
from seedsigner.controller import Controller
|
||||
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.psbt_parser import PSBTParser
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
from seedsigner.gui.screens import psbt_screens
|
||||
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen,
|
||||
LoadingScreenThread, QRDisplayScreen, WarningScreen)
|
||||
|
||||
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen
|
||||
from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, QRDisplayScreen)
|
||||
from seedsigner.views.view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
|
||||
|
||||
|
||||
class PSBTSelectSeedView(View):
|
||||
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
|
||||
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
|
||||
|
||||
def run(self):
|
||||
# Note: we can't just autoroute to the PSBT Overview because we might have a
|
||||
# multisig where we want to sign with more than one key on this device.
|
||||
if not self.controller.psbt:
|
||||
# Shouldn't be able to get here
|
||||
raise Exception("No PSBT currently loaded")
|
||||
|
||||
seeds = self.controller.storage.seeds
|
||||
|
||||
SCAN_SEED = (_("Scan a seed"), FontAwesomeIconConstants.QRCODE)
|
||||
TYPE_12WORD = (_("Enter 12-word seed"), FontAwesomeIconConstants.KEYBOARD)
|
||||
TYPE_24WORD = (_("Enter 24-word seed"), FontAwesomeIconConstants.KEYBOARD)
|
||||
self.SCAN_SEED = (_("Scan a seed"), SeedSignerIconConstants.QRCODE)
|
||||
self.TYPE_12WORD = (_("Enter 12-word seed"), FontAwesomeIconConstants.KEYBOARD)
|
||||
self.TYPE_24WORD = (_("Enter 24-word seed"), FontAwesomeIconConstants.KEYBOARD)
|
||||
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
|
||||
return Destination(PSBTOverviewView)
|
||||
|
||||
seeds = self.controller.storage.seeds
|
||||
button_data = []
|
||||
for seed in seeds:
|
||||
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
|
||||
@@ -42,22 +44,18 @@ class PSBTSelectSeedView(View):
|
||||
# Doesn't look like this seed can sign the current PSBT
|
||||
button_str += " (?)"
|
||||
|
||||
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
|
||||
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
|
||||
|
||||
button_data.append(SCAN_SEED)
|
||||
button_data.append(TYPE_12WORD)
|
||||
button_data.append(TYPE_24WORD)
|
||||
button_data.append(self.SCAN_SEED)
|
||||
button_data.append(self.TYPE_12WORD)
|
||||
button_data.append(self.TYPE_24WORD)
|
||||
|
||||
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
|
||||
return Destination(PSBTOverviewView)
|
||||
|
||||
selected_menu_num = ButtonListScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
ButtonListScreen,
|
||||
title=_("Select Signer"),
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
@@ -70,13 +68,13 @@ class PSBTSelectSeedView(View):
|
||||
# The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded.
|
||||
self.controller.resume_main_flow = Controller.FLOW__PSBT
|
||||
|
||||
if button_data[selected_menu_num] == SCAN_SEED:
|
||||
from seedsigner.views.scan_views import ScanView
|
||||
return Destination(ScanView)
|
||||
if button_data[selected_menu_num] == self.SCAN_SEED:
|
||||
from seedsigner.views.scan_views import ScanSeedQRView
|
||||
return Destination(ScanSeedQRView)
|
||||
|
||||
elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]:
|
||||
elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]:
|
||||
from seedsigner.views.seed_views import SeedMnemonicEntryView
|
||||
if button_data[selected_menu_num] == TYPE_12WORD:
|
||||
if button_data[selected_menu_num] == self.TYPE_12WORD:
|
||||
self.controller.storage.init_pending_mnemonic(num_words=12)
|
||||
else:
|
||||
self.controller.storage.init_pending_mnemonic(num_words=24)
|
||||
@@ -93,8 +91,10 @@ class PSBTOverviewView(View):
|
||||
if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed:
|
||||
# The PSBTParser takes a while to read the PSBT. Run the loading screen while
|
||||
# we wait.
|
||||
from seedsigner.gui.screens.screen import LoadingScreenThread
|
||||
self.loading_screen = LoadingScreenThread(text=_("Parsing PSBT..."))
|
||||
self.loading_screen.start()
|
||||
|
||||
try:
|
||||
self.controller.psbt_parser = PSBTParser(
|
||||
self.controller.psbt,
|
||||
@@ -129,23 +129,22 @@ class PSBTOverviewView(View):
|
||||
else:
|
||||
num_self_transfer_outputs += 1
|
||||
|
||||
screen = psbt_screens.PSBTOverviewScreen(
|
||||
# Everything is set. Stop the loading screen
|
||||
if self.loading_screen:
|
||||
self.loading_screen.stop()
|
||||
|
||||
# Run the overview screen
|
||||
selected_menu_num = self.run_screen(
|
||||
PSBTOverviewScreen,
|
||||
spend_amount=psbt_parser.spend_amount,
|
||||
change_amount=psbt_parser.change_amount,
|
||||
fee_amount=psbt_parser.fee_amount,
|
||||
num_inputs=psbt_parser.num_inputs,
|
||||
num_self_transfer_outputs=num_self_transfer_outputs,
|
||||
num_change_outputs=num_change_outputs,
|
||||
destination_addresses=psbt_parser.destination_addresses,
|
||||
destination_addresses=psbt_parser.destination_addresses
|
||||
)
|
||||
|
||||
# Everything is set. Stop the loading screen
|
||||
if self.loading_screen:
|
||||
self.loading_screen.stop()
|
||||
|
||||
# Run the overview screen
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
self.controller.psbt_seed = None
|
||||
return Destination(BackStackView)
|
||||
@@ -181,6 +180,8 @@ class PSBTUnsupportedScriptTypeWarningView(View):
|
||||
skip_current_view=True, # Prevent going BACK to WarningViews
|
||||
)
|
||||
|
||||
|
||||
|
||||
class PSBTNoChangeWarningView(View):
|
||||
def run(self):
|
||||
selected_menu_num = WarningScreen(
|
||||
@@ -216,14 +217,15 @@ class PSBTMathView(View):
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTMathScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
PSBTMathScreen,
|
||||
input_amount=psbt_parser.input_amount,
|
||||
num_inputs=psbt_parser.num_inputs,
|
||||
spend_amount=psbt_parser.spend_amount,
|
||||
num_recipients=psbt_parser.num_destinations,
|
||||
fee_amount=psbt_parser.fee_amount,
|
||||
change_amount=psbt_parser.change_amount,
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
@@ -250,7 +252,7 @@ class PSBTAddressDetailsView(View):
|
||||
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
raise Exception("Routing error")
|
||||
|
||||
# TRANSLATOR_NOTE: Future-tense used to indicate that this transaction will send this amount, as opposed to "Send" on its own which could be misread as an instant command (e.g. "Send Now").
|
||||
title = _("Will Send")
|
||||
@@ -264,34 +266,36 @@ class PSBTAddressDetailsView(View):
|
||||
# TRANSLATOR_NOTE: Short for "Next step"
|
||||
button_data.append(_("Next"))
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTAddressDetailsScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
PSBTAddressDetailsScreen,
|
||||
title=title,
|
||||
button_data=button_data,
|
||||
address=psbt_parser.destination_addresses[self.address_num],
|
||||
amount=psbt_parser.destination_amounts[self.address_num],
|
||||
).display()
|
||||
|
||||
if selected_menu_num == 0:
|
||||
if self.address_num < len(psbt_parser.destination_addresses) - 1:
|
||||
# Show the next receive addr
|
||||
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1})
|
||||
|
||||
elif psbt_parser.change_amount > 0:
|
||||
# Move on to display change
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
|
||||
|
||||
else:
|
||||
# There's no change output to verify. Move on to sign the PSBT.
|
||||
return Destination(PSBTFinalizeView)
|
||||
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
if self.address_num < len(psbt_parser.destination_addresses) - 1:
|
||||
# Show the next receive addr
|
||||
return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1})
|
||||
|
||||
elif psbt_parser.change_amount > 0:
|
||||
# Move on to display change
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": 0})
|
||||
|
||||
else:
|
||||
# There's no change output to verify. Move on to sign the PSBT.
|
||||
return Destination(PSBTFinalizeView)
|
||||
|
||||
|
||||
|
||||
class PSBTChangeDetailsView(View):
|
||||
"""
|
||||
"""
|
||||
NEXT = "Next"
|
||||
VERIFY_MULTISIG = "Verify Multisig Change"
|
||||
|
||||
|
||||
def __init__(self, change_address_num):
|
||||
super().__init__()
|
||||
self.change_address_num = change_address_num
|
||||
@@ -331,15 +335,15 @@ class PSBTChangeDetailsView(View):
|
||||
is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1
|
||||
derivation_path_addr_index = int(derivation_path.split("/")[-1])
|
||||
|
||||
NEXT = _("Next")
|
||||
self.NEXT = _("Next")
|
||||
|
||||
if is_change_derivation_path:
|
||||
# TRANSLATOR_NOTE: The amount you're receiving back from the transaction
|
||||
title = _("Your Change")
|
||||
VERIFY_MULTISIG = _("Verify Multisig Change")
|
||||
self.VERIFY_MULTISIG = _("Verify Multisig Change")
|
||||
else:
|
||||
title = _("Self-Transfer")
|
||||
VERIFY_MULTISIG = _("Verify Multisig Addr")
|
||||
self.VERIFY_MULTISIG = _("Verify Multisig Addr")
|
||||
# if psbt_parser.num_change_outputs > 1:
|
||||
# title += f" (#{self.change_address_num + 1})"
|
||||
|
||||
@@ -348,11 +352,11 @@ class PSBTChangeDetailsView(View):
|
||||
# if the known-good multisig descriptor is already onboard:
|
||||
if self.controller.multisig_wallet_descriptor:
|
||||
is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num)
|
||||
button_data = [NEXT]
|
||||
button_data = [self.NEXT]
|
||||
|
||||
else:
|
||||
# Have the Screen offer to load in the multisig descriptor.
|
||||
button_data = [VERIFY_MULTISIG, NEXT]
|
||||
button_data = [self.VERIFY_MULTISIG, self.NEXT]
|
||||
|
||||
else:
|
||||
# Single sig
|
||||
@@ -361,6 +365,7 @@ class PSBTChangeDetailsView(View):
|
||||
loading_screen_text = _("Verifying Change...")
|
||||
else:
|
||||
loading_screen_text = _("Verifying Self-Transfer...")
|
||||
from seedsigner.gui.screens.screen import LoadingScreenThread
|
||||
loading_screen = LoadingScreenThread(text=loading_screen_text)
|
||||
loading_screen.start()
|
||||
|
||||
@@ -394,7 +399,7 @@ class PSBTChangeDetailsView(View):
|
||||
|
||||
if change_data["address"] == calc_address:
|
||||
is_change_addr_verified = True
|
||||
button_data = [NEXT]
|
||||
button_data = [self.NEXT]
|
||||
|
||||
finally:
|
||||
loading_screen.stop()
|
||||
@@ -402,7 +407,8 @@ class PSBTChangeDetailsView(View):
|
||||
if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None):
|
||||
return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTChangeDetailsScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
PSBTChangeDetailsScreen,
|
||||
title=title,
|
||||
button_data=button_data,
|
||||
address=change_data.get("address"),
|
||||
@@ -413,19 +419,19 @@ class PSBTChangeDetailsView(View):
|
||||
is_change_derivation_path=is_change_derivation_path,
|
||||
derivation_path_addr_index=derivation_path_addr_index,
|
||||
is_change_addr_verified=is_change_addr_verified,
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == NEXT:
|
||||
elif button_data[selected_menu_num] == self.NEXT:
|
||||
if self.change_address_num < psbt_parser.num_change_outputs - 1:
|
||||
return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1})
|
||||
else:
|
||||
# There's no more change to verify. Move on to sign the PSBT.
|
||||
return Destination(PSBTFinalizeView)
|
||||
|
||||
elif button_data[selected_menu_num] == VERIFY_MULTISIG:
|
||||
elif button_data[selected_menu_num] == self.VERIFY_MULTISIG:
|
||||
from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView
|
||||
self.controller.resume_main_flow = Controller.FLOW__PSBT
|
||||
return Destination(LoadMultisigWalletDescriptorView)
|
||||
@@ -466,19 +472,27 @@ class PSBTAddressVerificationFailedView(View):
|
||||
class PSBTFinalizeView(View):
|
||||
"""
|
||||
"""
|
||||
APPROVE_PSBT = "Approve PSBT"
|
||||
|
||||
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
psbt: PSBT = self.controller.psbt
|
||||
self.APPROVE_PSBT = _("Approve PSBT")
|
||||
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
selected_menu_num = self.run_screen(
|
||||
PSBTFinalizeScreen,
|
||||
button_data=[self.APPROVE_PSBT]
|
||||
)
|
||||
|
||||
selected_menu_num = psbt_screens.PSBTFinalizeScreen(
|
||||
button_data=[_("Approve PSBT")]
|
||||
).display()
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
if selected_menu_num == 0:
|
||||
else:
|
||||
# Sign PSBT
|
||||
sig_cnt = PSBTParser.sig_count(psbt)
|
||||
psbt.sign_with(psbt_parser.root)
|
||||
@@ -494,9 +508,6 @@ class PSBTFinalizeView(View):
|
||||
self.controller.psbt = trimmed_psbt
|
||||
return Destination(PSBTSignedQRDisplayView)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
|
||||
|
||||
class PSBTSignedQRDisplayView(View):
|
||||
@@ -507,7 +518,7 @@ class PSBTSignedQRDisplayView(View):
|
||||
qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY),
|
||||
wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE),
|
||||
)
|
||||
QRDisplayScreen(qr_encoder=qr_encoder).display()
|
||||
self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder)
|
||||
|
||||
# We're done with this PSBT. Route back to MainMenuView which always
|
||||
# clears all ephemeral data (except in-memory seeds).
|
||||
@@ -516,22 +527,28 @@ class PSBTSignedQRDisplayView(View):
|
||||
|
||||
|
||||
class PSBTSigningErrorView(View):
|
||||
SELECT_DIFF_SEED = "Select Diff Seed"
|
||||
|
||||
def run(self):
|
||||
psbt_parser: PSBTParser = self.controller.psbt_parser
|
||||
self.SELECT_DIFF_SEED = _("Select Diff Seed")
|
||||
if not psbt_parser:
|
||||
# Should not be able to get here
|
||||
return Destination(MainMenuView)
|
||||
|
||||
# Just a WarningScreen here; only use DireWarningScreen for true security risks.
|
||||
selected_menu_num = WarningScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
WarningScreen,
|
||||
title=_("PSBT Error"),
|
||||
status_icon_name=SeedSignerCustomIconConstants.CIRCLE_EXCLAMATION,
|
||||
status_icon_name=SeedSignerIconConstants.WARNING,
|
||||
status_headline=_("Signing Failed"),
|
||||
text=_("Signing with this seed did not add a valid signature."),
|
||||
button_data=[_("Select Diff Seed")],
|
||||
).display()
|
||||
button_data=[self.SELECT_DIFF_SEED]
|
||||
)
|
||||
|
||||
if selected_menu_num == 0:
|
||||
# clear seed selected for psbt signing since it did not add a valid signature
|
||||
self.controller.psbt_seed = None
|
||||
return Destination(PSBTSelectSeedView, clear_history=True)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
|
||||
@@ -1,30 +1,70 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from embit.descriptor import Descriptor
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
|
||||
from seedsigner.models import DecodeQR, Seed
|
||||
from seedsigner.models.decode_qr import DecodeQR
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination
|
||||
from seedsigner.views.settings_views import SettingsIngestSettingsQRView
|
||||
from seedsigner.views.view import BackStackView, ErrorView, MainMenuView, NotYetImplementedView, OptionDisabledView, View, Destination
|
||||
|
||||
|
||||
|
||||
class ScanView(View):
|
||||
"""
|
||||
The catch-all generic scanning View that will accept any of our supported QR
|
||||
formats and will route to the most sensible next step.
|
||||
|
||||
Can also be used as a base class for more specific scanning flows with
|
||||
dedicated errors when an unexpected QR type is scanned (e.g. Scan PSBT was
|
||||
selected but a SeedQR was scanned).
|
||||
"""
|
||||
instructions_text = "Scan a QR code"
|
||||
invalid_qr_type_message = "QRCode not recognized or not yet supported."
|
||||
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Define the decoder here to make it available to child classes' is_valid_qr_type
|
||||
# checks and so we can inject data into it in the test suite's `before_run()`.
|
||||
self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
self.decoder: DecodeQR = DecodeQR(wordlist_language_code=self.wordlist_language_code)
|
||||
|
||||
|
||||
@property
|
||||
def is_valid_qr_type(self):
|
||||
return True
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.scan_screens import ScanScreen
|
||||
|
||||
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code)
|
||||
|
||||
# Start the live preview and background QR reading
|
||||
ScanScreen(decoder=self.decoder).display()
|
||||
self.run_screen(
|
||||
ScanScreen,
|
||||
instructions_text=self.instructions_text,
|
||||
decoder=self.decoder
|
||||
)
|
||||
|
||||
# Handle the results
|
||||
if self.decoder.is_complete:
|
||||
if not self.is_valid_qr_type:
|
||||
# We recognized the QR type but it was not the type expected for the
|
||||
# current flow.
|
||||
# Report QR types in more human-readable text (e.g. QRType
|
||||
# `seed__compactseedqr` as "seed: compactseedqr").
|
||||
return Destination(ErrorView, view_args=dict(
|
||||
title="Error",
|
||||
status_headline="Wrong QR Type",
|
||||
text=self.invalid_qr_type_message + f""", received "{self.decoder.qr_type.replace("__", ": ").replace("_", " ")}\" format""",
|
||||
button_text="Back",
|
||||
next_destination=Destination(BackStackView, skip_current_view=True),
|
||||
))
|
||||
|
||||
if self.decoder.is_seed:
|
||||
seed_mnemonic = self.decoder.get_seed_phrase()
|
||||
|
||||
if not seed_mnemonic:
|
||||
# seed is not valid, Exit if not valid with message
|
||||
return Destination(NotYetImplementedView)
|
||||
@@ -33,7 +73,7 @@ class ScanView(View):
|
||||
# pending (might set a passphrase, SeedXOR, etc) until finalized.
|
||||
from .seed_views import SeedFinalizeView
|
||||
self.controller.storage.set_pending_seed(
|
||||
Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code)
|
||||
Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code)
|
||||
)
|
||||
if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED:
|
||||
from seedsigner.views.seed_views import SeedAddPassphraseView
|
||||
@@ -49,13 +89,8 @@ class ScanView(View):
|
||||
return Destination(PSBTSelectSeedView, skip_current_view=True)
|
||||
|
||||
elif self.decoder.is_settings:
|
||||
from seedsigner.models.settings import Settings
|
||||
settings = self.decoder.get_settings_data()
|
||||
Settings.get_instance().update(new_settings=settings)
|
||||
|
||||
print(json.dumps(Settings.get_instance()._data, indent=4))
|
||||
|
||||
return Destination(SettingsUpdatedView, {"config_name": self.decoder.get_settings_config_name()})
|
||||
data = self.decoder.get_settings_data()
|
||||
return Destination(SettingsIngestSettingsQRView, view_args=dict(data=data))
|
||||
|
||||
elif self.decoder.is_wallet_descriptor:
|
||||
from seedsigner.views.seed_views import MultisigWalletDescriptorView
|
||||
@@ -100,31 +135,71 @@ class ScanView(View):
|
||||
}
|
||||
)
|
||||
|
||||
elif self.decoder.is_sign_message:
|
||||
from seedsigner.views.seed_views import SeedSignMessageStartView
|
||||
qr_data = self.decoder.get_qr_data()
|
||||
|
||||
return Destination(
|
||||
SeedSignMessageStartView,
|
||||
view_args=dict(
|
||||
derivation_path=qr_data["derivation_path"],
|
||||
message=qr_data["message"],
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
return Destination(NotYetImplementedView)
|
||||
|
||||
elif self.decoder.is_invalid:
|
||||
raise Exception("QRCode not recognized or not yet supported.")
|
||||
# For now, don't even try to re-do the attempted operation, just reset and
|
||||
# start everything over.
|
||||
self.controller.resume_main_flow = None
|
||||
return Destination(ErrorView, view_args=dict(
|
||||
title="Error",
|
||||
status_headline="Unknown QR Type",
|
||||
text="QRCode is invalid or is a data format not yet supported.",
|
||||
button_text="Done",
|
||||
next_destination=Destination(MainMenuView, clear_history=True),
|
||||
))
|
||||
|
||||
return Destination(MainMenuView)
|
||||
|
||||
|
||||
|
||||
class SettingsUpdatedView(View):
|
||||
def __init__(self, config_name: str):
|
||||
super().__init__()
|
||||
class ScanPSBTView(ScanView):
|
||||
instructions_text = "Scan PSBT"
|
||||
invalid_qr_type_message = "Expected a PSBT"
|
||||
|
||||
self.config_name = config_name
|
||||
|
||||
@property
|
||||
def is_valid_qr_type(self):
|
||||
return self.decoder.is_psbt
|
||||
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.scan_screens import SettingsUpdatedScreen
|
||||
screen = SettingsUpdatedScreen(config_name=self.config_name)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
# Only one exit point
|
||||
return Destination(MainMenuView)
|
||||
class ScanSeedQRView(ScanView):
|
||||
instructions_text = "Scan SeedQR"
|
||||
invalid_qr_type_message = f"Expected a SeedQR"
|
||||
|
||||
@property
|
||||
def is_valid_qr_type(self):
|
||||
return self.decoder.is_seed
|
||||
|
||||
|
||||
|
||||
class ScanWalletDescriptorView(ScanView):
|
||||
instructions_text = "Scan descriptor"
|
||||
invalid_qr_type_message = "Expected a wallet descriptor QR"
|
||||
|
||||
@property
|
||||
def is_valid_qr_type(self):
|
||||
return self.decoder.is_wallet_descriptor
|
||||
|
||||
|
||||
|
||||
class ScanAddressView(ScanView):
|
||||
instructions_text = "Scan address QR"
|
||||
invalid_qr_type_message = "Expected an address QR"
|
||||
|
||||
@property
|
||||
def is_valid_qr_type(self):
|
||||
return self.decoder.is_address
|
||||
|
||||
@@ -91,7 +91,6 @@ class OpeningSplashScreen(LogoScreen):
|
||||
|
||||
|
||||
|
||||
|
||||
class ScreensaverScreen(LogoScreen):
|
||||
def __init__(self, buttons):
|
||||
super().__init__()
|
||||
@@ -143,9 +142,9 @@ class ScreensaverScreen(LogoScreen):
|
||||
# never gives up the lock until it returns.
|
||||
with self.renderer.lock:
|
||||
try:
|
||||
while True:
|
||||
while self._is_running:
|
||||
if self.buttons.has_any_input() or self.buttons.override_ind:
|
||||
return self.stop()
|
||||
break
|
||||
|
||||
# Must crop the image to the exact display size
|
||||
crop = self.image.crop((
|
||||
@@ -178,20 +177,23 @@ class ScreensaverScreen(LogoScreen):
|
||||
self.increment_y = self.rand_increment()
|
||||
if self.increment_y > 0.0:
|
||||
self.increment_y *= -1.0
|
||||
|
||||
except KeyboardInterrupt as e:
|
||||
# Exit triggered; close gracefully
|
||||
print("Shutting down Screensaver")
|
||||
self.stop()
|
||||
|
||||
# Have to let the interrupt bubble up to exit the main app
|
||||
raise e
|
||||
|
||||
finally:
|
||||
self._is_running = False
|
||||
|
||||
# Restore the original screen
|
||||
self.renderer.show_image(self.last_screen)
|
||||
|
||||
|
||||
|
||||
def stop(self):
|
||||
# Restore the original screen
|
||||
self.renderer.show_image(self.last_screen)
|
||||
|
||||
self._is_running = False
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
from gettext import gettext as _
|
||||
import logging
|
||||
from seedsigner.gui.components import SeedSignerIconConstants
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
|
||||
from seedsigner.gui.components import SeedSignerCustomIconConstants
|
||||
from .view import View, Destination, MainMenuView
|
||||
|
||||
from seedsigner.gui.components import SeedSignerIconConstants
|
||||
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens)
|
||||
from seedsigner.models.settings import SettingsConstants, SettingsDefinition
|
||||
from seedsigner.models.settings import Settings, SettingsConstants, SettingsDefinition
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .view import View, Destination, BackStackView, MainMenuView
|
||||
|
||||
|
||||
|
||||
class SettingsMenuView(View):
|
||||
IO_TEST = "I/O test"
|
||||
DONATE = "Donate"
|
||||
|
||||
def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0):
|
||||
super().__init__()
|
||||
self.visibility = visibility
|
||||
@@ -19,8 +29,8 @@ class SettingsMenuView(View):
|
||||
|
||||
|
||||
def run(self):
|
||||
IO_TEST = _("I/O test")
|
||||
DONATE = _("Donate")
|
||||
self.IO_TEST = _("I/O test")
|
||||
self.DONATE = _("Donate")
|
||||
|
||||
settings_entries = SettingsDefinition.get_settings_entries(
|
||||
visibility=self.visibility
|
||||
@@ -38,35 +48,35 @@ class SettingsMenuView(View):
|
||||
title = _("Settings")
|
||||
|
||||
# Set up the next nested level of menuing
|
||||
button_data.append((_("Advanced"), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
|
||||
next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
button_data.append((_("Advanced"), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
|
||||
next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
|
||||
button_data.append(IO_TEST)
|
||||
button_data.append(DONATE)
|
||||
button_data.append(self.IO_TEST)
|
||||
button_data.append(self.DONATE)
|
||||
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED:
|
||||
title = _("Advanced")
|
||||
|
||||
# So far there are no real Developer options; disabling for now
|
||||
# button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
|
||||
# next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
|
||||
next = None
|
||||
# button_data.append(("Developer Options", None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
|
||||
# next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER})
|
||||
next_destination = None
|
||||
|
||||
elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER:
|
||||
title = _("Dev Options")
|
||||
next = None
|
||||
next_destination = None
|
||||
|
||||
screen = ButtonListScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
ButtonListScreen,
|
||||
title=title,
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data,
|
||||
selected_button=selected_button,
|
||||
scroll_y_initial_offset=self.initial_scroll,
|
||||
)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
# Preserve our scroll position in this Screen so we can return
|
||||
initial_scroll = screen.buttons[0].scroll_y
|
||||
initial_scroll = self.screen.buttons[0].scroll_y
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
if self.visibility == SettingsConstants.VISIBILITY__GENERAL:
|
||||
@@ -77,12 +87,12 @@ class SettingsMenuView(View):
|
||||
return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED})
|
||||
|
||||
elif selected_menu_num == len(settings_entries):
|
||||
return next
|
||||
return next_destination
|
||||
|
||||
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST:
|
||||
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST:
|
||||
return Destination(IOTestView)
|
||||
|
||||
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == DONATE:
|
||||
elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE:
|
||||
return Destination(DonateView)
|
||||
|
||||
else:
|
||||
@@ -95,10 +105,10 @@ class SettingsEntryUpdateSelectionView(View):
|
||||
Handles changes to all selection-type settings (Multiselect, SELECT_1,
|
||||
Enabled/Disabled, etc).
|
||||
"""
|
||||
def __init__(self, attr_name: str, parent_initial_scroll: int = 0):
|
||||
def __init__(self, attr_name: str, parent_initial_scroll: int = 0, selected_button: int = None):
|
||||
super().__init__()
|
||||
self.settings_entry = SettingsDefinition.get_settings_entry(attr_name)
|
||||
self.selected_button = None
|
||||
self.selected_button = selected_button
|
||||
self.parent_initial_scroll = parent_initial_scroll
|
||||
|
||||
|
||||
@@ -112,6 +122,7 @@ class SettingsEntryUpdateSelectionView(View):
|
||||
else:
|
||||
display_name = value
|
||||
button_data.append(_(display_name))
|
||||
|
||||
if (type(initial_value) == list and value in initial_value) or value == initial_value:
|
||||
checked_buttons.append(i)
|
||||
|
||||
@@ -120,17 +131,18 @@ class SettingsEntryUpdateSelectionView(View):
|
||||
# selected option).
|
||||
self.selected_button = i
|
||||
|
||||
if not self.selected_button:
|
||||
if self.selected_button is None:
|
||||
self.selected_button = 0
|
||||
|
||||
ret_value = settings_screens.SettingsEntryUpdateSelectionScreen(
|
||||
|
||||
ret_value = self.run_screen(
|
||||
settings_screens.SettingsEntryUpdateSelectionScreen,
|
||||
display_name=self.settings_entry.display_name,
|
||||
help_text=self.settings_entry.help_text,
|
||||
button_data=button_data,
|
||||
selected_button=self.selected_button,
|
||||
checked_buttons=checked_buttons,
|
||||
settings_entry_type=self.settings_entry.type,
|
||||
).display()
|
||||
)
|
||||
|
||||
destination = None
|
||||
settings_menu_view_destination = Destination(
|
||||
@@ -178,7 +190,37 @@ class SettingsEntryUpdateSelectionView(View):
|
||||
|
||||
# All selects stay in place; re-initialize where in the list we left off
|
||||
self.selected_button = ret_value
|
||||
return self.run()
|
||||
|
||||
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll, selected_button=self.selected_button), skip_current_view=True)
|
||||
|
||||
|
||||
|
||||
class SettingsIngestSettingsQRView(View):
|
||||
def __init__(self, data: str):
|
||||
super().__init__()
|
||||
|
||||
# May raise an Exception which will bubble up to the Controller to display to the
|
||||
# user.
|
||||
self.config_name, settings_update_dict = Settings.parse_settingsqr(data)
|
||||
|
||||
self.settings.update(settings_update_dict)
|
||||
|
||||
if MicroSD.get_instance().is_inserted and self.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED:
|
||||
self.status_message = "Persistent Settings enabled. Settings saved to SD card."
|
||||
else:
|
||||
self.status_message = "Settings updated in temporary memory"
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.settings_screens import SettingsQRConfirmationScreen
|
||||
self.run_screen(
|
||||
SettingsQRConfirmationScreen,
|
||||
config_name=self.config_name,
|
||||
status_message=self.status_message,
|
||||
)
|
||||
|
||||
# Only one exit point
|
||||
return Destination(MainMenuView)
|
||||
|
||||
|
||||
|
||||
@@ -187,7 +229,7 @@ class SettingsEntryUpdateSelectionView(View):
|
||||
****************************************************************************"""
|
||||
class IOTestView(View):
|
||||
def run(self):
|
||||
settings_screens.IOTestScreen().display()
|
||||
self.run_screen(settings_screens.IOTestScreen)
|
||||
|
||||
return Destination(SettingsMenuView)
|
||||
|
||||
@@ -195,6 +237,6 @@ class IOTestView(View):
|
||||
|
||||
class DonateView(View):
|
||||
def run(self):
|
||||
settings_screens.DonateScreen().display()
|
||||
self.run_screen(settings_screens.DonateScreen)
|
||||
|
||||
return Destination(SettingsMenuView)
|
||||
|
||||
@@ -1,59 +1,71 @@
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
import time
|
||||
|
||||
from gettext import gettext as _
|
||||
from embit.descriptor import Descriptor
|
||||
from PIL import Image
|
||||
from PIL.ImageOps import autocontrast
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen
|
||||
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerCustomIconConstants
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerIconConstants
|
||||
from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen)
|
||||
from seedsigner.gui.screens.tools_screens import ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordFinalizePromptScreen, ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen, ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen
|
||||
from seedsigner.gui.screens.tools_screens import (ToolsCalcFinalWordDoneScreen, ToolsCalcFinalWordFinalizePromptScreen,
|
||||
ToolsCalcFinalWordScreen, ToolsCoinFlipEntryScreen, ToolsDiceEntropyEntryScreen, ToolsImageEntropyFinalImageScreen,
|
||||
ToolsImageEntropyLivePreviewScreen, ToolsAddressExplorerAddressTypeScreen)
|
||||
from seedsigner.helpers import embit_utils, mnemonic_generation
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedWordsWarningView, SeedExportXpubScriptTypeView
|
||||
from seedsigner.views.seed_views import SeedDiscardView, SeedFinalizeView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView, SeedExportXpubScriptTypeView
|
||||
|
||||
from .view import View, Destination, BackStackView
|
||||
|
||||
|
||||
|
||||
class ToolsMenuView(View):
|
||||
IMAGE = ("New seed", FontAwesomeIconConstants.CAMERA)
|
||||
DICE = ("New seed", FontAwesomeIconConstants.DICE)
|
||||
KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD)
|
||||
EXPLORER = "Address Explorer"
|
||||
ADDRESS = "Verify address"
|
||||
|
||||
def run(self):
|
||||
IMAGE = (_(" New seed"), FontAwesomeIconConstants.CAMERA)
|
||||
DICE = (_("New seed"), FontAwesomeIconConstants.DICE)
|
||||
KEYBOARD = (_("Calc 12th/24th word"), FontAwesomeIconConstants.KEYBOARD)
|
||||
EXPLORER = _("Address Explorer")
|
||||
button_data = [IMAGE, DICE, KEYBOARD, EXPLORER]
|
||||
screen = ButtonListScreen(
|
||||
self.IMAGE = (_("New seed"), FontAwesomeIconConstants.CAMERA)
|
||||
self.DICE = (_("New seed"), FontAwesomeIconConstants.DICE)
|
||||
self.KEYBOARD = (_("Calc 12th/24th word"), FontAwesomeIconConstants.KEYBOARD)
|
||||
self.EXPLORER = _("Address Explorer")
|
||||
button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER, self.ADDRESS]
|
||||
|
||||
selected_menu_num = self.run_screen(
|
||||
ButtonListScreen,
|
||||
title=_("Tools"),
|
||||
is_button_text_centered=False,
|
||||
button_data=button_data
|
||||
)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == IMAGE:
|
||||
elif button_data[selected_menu_num] == self.IMAGE:
|
||||
return Destination(ToolsImageEntropyLivePreviewView)
|
||||
|
||||
elif button_data[selected_menu_num] == DICE:
|
||||
elif button_data[selected_menu_num] == self.DICE:
|
||||
return Destination(ToolsDiceEntropyMnemonicLengthView)
|
||||
|
||||
elif button_data[selected_menu_num] == KEYBOARD:
|
||||
elif button_data[selected_menu_num] == self.KEYBOARD:
|
||||
return Destination(ToolsCalcFinalWordNumWordsView)
|
||||
|
||||
elif button_data[selected_menu_num] == EXPLORER:
|
||||
elif button_data[selected_menu_num] == self.EXPLORER:
|
||||
return Destination(ToolsAddressExplorerSelectSourceView)
|
||||
|
||||
elif button_data[selected_menu_num] == self.ADDRESS:
|
||||
from seedsigner.views.scan_views import ScanAddressView
|
||||
return Destination(ScanAddressView)
|
||||
|
||||
|
||||
|
||||
"""****************************************************************************
|
||||
Image entropy Views
|
||||
@@ -74,6 +86,7 @@ class ToolsImageEntropyLivePreviewView(View):
|
||||
class ToolsImageEntropyFinalImageView(View):
|
||||
def run(self):
|
||||
if not self.controller.image_entropy_final_image:
|
||||
from seedsigner.hardware.camera import Camera
|
||||
# Take the final full-res image
|
||||
camera = Camera.get_instance()
|
||||
camera.start_single_frame_mode(resolution=(720, 480))
|
||||
@@ -216,9 +229,7 @@ class ToolsDiceEntropyEntryView(View):
|
||||
if ret == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
print(f"Dice rolls: {ret}")
|
||||
dice_seed_phrase = mnemonic_generation.generate_mnemonic_from_dice(ret)
|
||||
print(f"""Mnemonic: "{dice_seed_phrase}" """)
|
||||
|
||||
# Add the mnemonic as an in-memory Seed
|
||||
seed = Seed(dice_seed_phrase, wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
|
||||
@@ -233,28 +244,33 @@ class ToolsDiceEntropyEntryView(View):
|
||||
Calc final word Views
|
||||
****************************************************************************"""
|
||||
class ToolsCalcFinalWordNumWordsView(View):
|
||||
TWELVE = "12 words"
|
||||
TWENTY_FOUR = "24 words"
|
||||
|
||||
def run(self):
|
||||
TWELVE = _("12 words")
|
||||
TWENTY_FOUR = _("24 words")
|
||||
self.TWELVE = _("12 words")
|
||||
self.TWENTY_FOUR = _("24 words")
|
||||
|
||||
button_data = [TWELVE, TWENTY_FOUR]
|
||||
selected_menu_num = ButtonListScreen(
|
||||
button_data = [self.TWELVE, self.TWENTY_FOUR]
|
||||
|
||||
selected_menu_num = self.run_screen(
|
||||
ButtonListScreen,
|
||||
title=_("Mnemonic Length"),
|
||||
is_bottom_list=True,
|
||||
is_button_text_centered=True,
|
||||
button_data=button_data,
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == TWELVE:
|
||||
elif button_data[selected_menu_num] == self.TWELVE:
|
||||
self.controller.storage.init_pending_mnemonic(12)
|
||||
|
||||
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
|
||||
return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
|
||||
|
||||
elif button_data[selected_menu_num] == TWENTY_FOUR:
|
||||
elif button_data[selected_menu_num] == self.TWENTY_FOUR:
|
||||
self.controller.storage.init_pending_mnemonic(24)
|
||||
|
||||
# return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True))
|
||||
@@ -307,8 +323,7 @@ class ToolsCalcFinalWordFinalizePromptView(View):
|
||||
|
||||
class ToolsCalcFinalWordCoinFlipsView(View):
|
||||
def run(self):
|
||||
mnemonic = self.controller.storage.pending_mnemonic
|
||||
mnemonic_length = len(mnemonic)
|
||||
mnemonic_length = len(self.controller.storage.pending_mnemonic)
|
||||
|
||||
if mnemonic_length == 12:
|
||||
total_flips = 7
|
||||
@@ -323,70 +338,76 @@ class ToolsCalcFinalWordCoinFlipsView(View):
|
||||
return Destination(BackStackView)
|
||||
|
||||
else:
|
||||
print(ret_val)
|
||||
binary_string = ret_val + "0" * (11 - total_flips)
|
||||
wordlist_index = int(binary_string, 2)
|
||||
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
|
||||
word = wordlist[wordlist_index]
|
||||
self.controller.storage.update_pending_mnemonic(word, mnemonic_length - 1)
|
||||
|
||||
return Destination(ToolsCalcFinalWordShowFinalWordView, view_args=dict(coin_flips=ret_val))
|
||||
|
||||
|
||||
|
||||
class ToolsCalcFinalWordShowFinalWordView(View):
|
||||
def __init__(self, coin_flips=None):
|
||||
def __init__(self, coin_flips: str = None):
|
||||
super().__init__()
|
||||
self.coin_flips = coin_flips
|
||||
|
||||
|
||||
def run(self):
|
||||
# Construct the actual final word. The user's selected_final_word
|
||||
# contributes:
|
||||
# * 3 bits to a 24-word seed (plus 8-bit checksum)
|
||||
# * 7 bits to a 12-word seed (plus 4-bit checksum)
|
||||
from seedsigner.helpers import mnemonic_generation
|
||||
|
||||
mnemonic = self.controller.storage.pending_mnemonic
|
||||
mnemonic_length = len(mnemonic)
|
||||
wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)
|
||||
wordlist = Seed.get_wordlist(wordlist_language_code)
|
||||
|
||||
# Prep the user's selected word / coin flips and the actual final word for
|
||||
# the display.
|
||||
if coin_flips:
|
||||
self.selected_final_word = None
|
||||
self.selected_final_bits = coin_flips
|
||||
else:
|
||||
# Convert the user's final word selection into its binary index equivalent
|
||||
self.selected_final_word = self.controller.storage.pending_mnemonic[-1]
|
||||
self.selected_final_bits = format(wordlist.index(self.selected_final_word), '011b')
|
||||
|
||||
if coin_flips:
|
||||
# fill the last bits (what will eventually be the checksum) with zeros
|
||||
binary_string = coin_flips + "0" * (11 - len(coin_flips))
|
||||
|
||||
# retrieve the matching word for the resulting index
|
||||
wordlist_index = int(binary_string, 2)
|
||||
wordlist = Seed.get_wordlist(self.controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE))
|
||||
word = wordlist[wordlist_index]
|
||||
|
||||
# update the pending mnemonic with our new "final" (pre-checksum) word
|
||||
self.controller.storage.update_pending_mnemonic(word, -1)
|
||||
|
||||
# Now calculate the REAL final word (has a proper checksum)
|
||||
final_mnemonic = mnemonic_generation.calculate_checksum(
|
||||
mnemonic=self.controller.storage.pending_mnemonic,
|
||||
wordlist_language_code=wordlist_language_code,
|
||||
)
|
||||
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], mnemonic_length - 1)
|
||||
|
||||
# Prep the user's selected word (if there was one) and the actual final word for
|
||||
# the display.
|
||||
if self.coin_flips:
|
||||
selected_final_word = None
|
||||
selected_final_bits = self.coin_flips
|
||||
else:
|
||||
# Convert the user's final word selection into its binary index equivalent
|
||||
selected_final_word = mnemonic[-1]
|
||||
selected_final_bits = format(wordlist.index(selected_final_word), '011b')
|
||||
# Update our pending mnemonic with the real final word
|
||||
self.controller.storage.update_pending_mnemonic(final_mnemonic[-1], -1)
|
||||
|
||||
mnemonic = self.controller.storage.pending_mnemonic
|
||||
mnemonic_length = len(mnemonic)
|
||||
|
||||
# And grab the actual final word's checksum bits
|
||||
actual_final_word = self.controller.storage.pending_mnemonic[-1]
|
||||
if mnemonic_length == 12:
|
||||
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-4:]
|
||||
else:
|
||||
checksum_bits = format(wordlist.index(actual_final_word), '011b')[-8:]
|
||||
self.actual_final_word = self.controller.storage.pending_mnemonic[-1]
|
||||
num_checksum_bits = 4 if mnemonic_length == 12 else 8
|
||||
self.checksum_bits = format(wordlist.index(self.actual_final_word), '011b')[-num_checksum_bits:]
|
||||
|
||||
|
||||
def run(self):
|
||||
# l10n_note already exists.
|
||||
NEXT = _("Next")
|
||||
button_data = [NEXT]
|
||||
selected_menu_num = ToolsCalcFinalWordScreen(
|
||||
selected_menu_num = self.run_screen(
|
||||
ToolsCalcFinalWordScreen,
|
||||
# TRANSLATOR_NOTE: label to calculate the last word of a BIP-39 mnemonic seed phrase
|
||||
title=_("Final Word Calc"),
|
||||
button_data=button_data,
|
||||
selected_final_word=selected_final_word,
|
||||
selected_final_bits=selected_final_bits,
|
||||
checksum_bits=checksum_bits,
|
||||
actual_final_word=actual_final_word,
|
||||
).display()
|
||||
selected_final_word=self.selected_final_word,
|
||||
selected_final_bits=self.selected_final_bits,
|
||||
checksum_bits=self.checksum_bits,
|
||||
actual_final_word=self.actual_final_word,
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
@@ -429,29 +450,31 @@ class ToolsCalcFinalWordDoneView(View):
|
||||
Address Explorer Views
|
||||
****************************************************************************"""
|
||||
class ToolsAddressExplorerSelectSourceView(View):
|
||||
def run(self):
|
||||
SCAN_SEED = (_("Scan a seed"), FontAwesomeIconConstants.QRCODE)
|
||||
SCAN_DESCRIPTOR = (_("Scan wallet descriptor"), FontAwesomeIconConstants.QRCODE)
|
||||
TYPE_12WORD = (_("Enter 12-word seed"), FontAwesomeIconConstants.KEYBOARD)
|
||||
TYPE_24WORD = (_("Enter 24-word seed"), FontAwesomeIconConstants.KEYBOARD)
|
||||
button_data = []
|
||||
SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
|
||||
SCAN_DESCRIPTOR = ("Scan wallet descriptor", SeedSignerIconConstants.QRCODE)
|
||||
TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
|
||||
|
||||
def run(self):
|
||||
seeds = self.controller.storage.seeds
|
||||
self.SCAN_SEED = ("Scan a seed", SeedSignerIconConstants.QRCODE)
|
||||
self.SCAN_DESCRIPTOR = ("Scan wallet descriptor", SeedSignerIconConstants.QRCODE)
|
||||
self.TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
self.TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD)
|
||||
button_data = []
|
||||
for seed in seeds:
|
||||
button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK))
|
||||
button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue"))
|
||||
|
||||
button_data.append(SCAN_SEED)
|
||||
button_data.append(SCAN_DESCRIPTOR)
|
||||
button_data.append(TYPE_12WORD)
|
||||
button_data.append(TYPE_24WORD)
|
||||
|
||||
selected_menu_num = ButtonListScreen(
|
||||
button_data.append((button_str, SeedSignerIconConstants.FINGERPRINT))
|
||||
button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD]
|
||||
|
||||
selected_menu_num = self.run_screen(
|
||||
ButtonListScreen,
|
||||
title=_("Address Explorer"),
|
||||
button_data=button_data,
|
||||
is_button_text_centered=False,
|
||||
is_bottom_list=True,
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
@@ -471,13 +494,17 @@ class ToolsAddressExplorerSelectSourceView(View):
|
||||
)
|
||||
)
|
||||
|
||||
elif button_data[selected_menu_num] in [SCAN_SEED, SCAN_DESCRIPTOR]:
|
||||
from seedsigner.views.scan_views import ScanView
|
||||
return Destination(ScanView)
|
||||
elif button_data[selected_menu_num] == self.SCAN_SEED:
|
||||
from seedsigner.views.scan_views import ScanSeedQRView
|
||||
return Destination(ScanSeedQRView)
|
||||
|
||||
elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]:
|
||||
elif button_data[selected_menu_num] == self.SCAN_DESCRIPTOR:
|
||||
from seedsigner.views.scan_views import ScanWalletDescriptorView
|
||||
return Destination(ScanWalletDescriptorView)
|
||||
|
||||
elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]:
|
||||
from seedsigner.views.seed_views import SeedMnemonicEntryView
|
||||
if button_data[selected_menu_num] == TYPE_12WORD:
|
||||
if button_data[selected_menu_num] == self.TYPE_12WORD:
|
||||
self.controller.storage.init_pending_mnemonic(num_words=12)
|
||||
else:
|
||||
self.controller.storage.init_pending_mnemonic(num_words=24)
|
||||
@@ -486,6 +513,10 @@ class ToolsAddressExplorerSelectSourceView(View):
|
||||
|
||||
|
||||
class ToolsAddressExplorerAddressTypeView(View):
|
||||
RECEIVE = "Receive Addresses"
|
||||
CHANGE = "Change Addresses"
|
||||
|
||||
|
||||
def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None):
|
||||
"""
|
||||
If the explorer source is a seed, `seed_num` and `script_type` must be
|
||||
@@ -535,10 +566,9 @@ class ToolsAddressExplorerAddressTypeView(View):
|
||||
data = self.controller.address_explorer_data
|
||||
|
||||
# TRANSLATOR_NOTE: label for addresses where others send us incoming payments
|
||||
RECEIVE = _("Receive Addresses")
|
||||
self.RECEIVE = _("Receive Addresses")
|
||||
# TRANSLATOR_NOTE: label for addresses that collect the change from our own outgoing payments
|
||||
CHANGE = _("Change Addresses")
|
||||
button_data = [RECEIVE, CHANGE]
|
||||
self.CHANGE = _("Change Addresses")
|
||||
|
||||
wallet_descriptor_display_name = None
|
||||
if "wallet_descriptor" in data:
|
||||
@@ -547,19 +577,30 @@ class ToolsAddressExplorerAddressTypeView(View):
|
||||
|
||||
script_type = data["script_type"] if "script_type" in data else None
|
||||
|
||||
selected_menu_num = ToolsAddressExplorerAddressTypeScreen(
|
||||
button_data = [self.RECEIVE, self.CHANGE]
|
||||
|
||||
selected_menu_num = self.run_screen(
|
||||
ToolsAddressExplorerAddressTypeScreen,
|
||||
button_data=button_data,
|
||||
fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None,
|
||||
wallet_descriptor_display_name=wallet_descriptor_display_name,
|
||||
script_type=script_type,
|
||||
custom_derivation_path=self.custom_derivation,
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
# If we entered this flow via an already-loaded seed's SeedOptionsView, we
|
||||
# need to clear the `resume_main_flow` so that we don't get stuck in a
|
||||
# SeedOptionsView redirect loop.
|
||||
# TODO: Refactor to a cleaner `BackStack.get_previous_View_cls()`
|
||||
if len(self.controller.back_stack) > 1 and self.controller.back_stack[-2].View_cls == SeedOptionsView:
|
||||
# The BackStack has the current View on the top with the real "back" in second position.
|
||||
self.controller.resume_main_flow = None
|
||||
self.controller.address_explorer_data = None
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] in [RECEIVE, CHANGE]:
|
||||
return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == CHANGE))
|
||||
elif button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]:
|
||||
return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == self.CHANGE))
|
||||
|
||||
|
||||
|
||||
@@ -574,21 +615,23 @@ class ToolsAddressExplorerAddressListView(View):
|
||||
|
||||
def run(self):
|
||||
self.loading_screen = None
|
||||
try:
|
||||
addresses = []
|
||||
button_data = []
|
||||
data = self.controller.address_explorer_data
|
||||
addrs_per_screen = 10
|
||||
|
||||
addr_storage_key = "receive_addrs"
|
||||
if self.is_change:
|
||||
addr_storage_key = "change_addrs"
|
||||
addresses = []
|
||||
button_data = []
|
||||
data = self.controller.address_explorer_data
|
||||
addrs_per_screen = 10
|
||||
|
||||
if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen:
|
||||
# We already calculated this range addresses; just retrieve them
|
||||
addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen]
|
||||
addr_storage_key = "receive_addrs"
|
||||
if self.is_change:
|
||||
addr_storage_key = "change_addrs"
|
||||
|
||||
else:
|
||||
if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen:
|
||||
# We already calculated this range of addresses; just retrieve them
|
||||
addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen]
|
||||
|
||||
else:
|
||||
try:
|
||||
from seedsigner.gui.screens.screen import LoadingScreenThread
|
||||
# TRANSLATOR_NOTE: a status message that our payment addresses are being calculated
|
||||
self.loading_screen = LoadingScreenThread(text=_("Calculating addrs..."))
|
||||
self.loading_screen.start()
|
||||
@@ -618,35 +661,36 @@ class ToolsAddressExplorerAddressListView(View):
|
||||
|
||||
else:
|
||||
raise Exception("Single sig descriptors not yet supported")
|
||||
|
||||
for i, address in enumerate(addresses):
|
||||
cur_index = i + self.start_index
|
||||
if cur_index < 10:
|
||||
end_digits = -6
|
||||
elif cur_index < 100:
|
||||
end_digits = -5
|
||||
else:
|
||||
end_digits = -4
|
||||
button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}")
|
||||
|
||||
button_data.append((_("Next {}").format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT))
|
||||
|
||||
screen = ButtonListScreen(
|
||||
title=_("Receive Addrs") if not self.is_change else _("Change Addrs"),
|
||||
button_data=button_data,
|
||||
button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
button_font_size=GUIConstants.get_button_font_size() + 4,
|
||||
is_button_text_centered=False,
|
||||
is_bottom_list=True,
|
||||
selected_button=self.selected_button_index,
|
||||
scroll_y_initial_offset=self.initial_scroll,
|
||||
)
|
||||
finally:
|
||||
# Everything is set. Stop the loading screen
|
||||
if self.loading_screen:
|
||||
finally:
|
||||
# Everything is set. Stop the loading screen
|
||||
self.loading_screen.stop()
|
||||
|
||||
selected_menu_num = screen.display()
|
||||
for i, address in enumerate(addresses):
|
||||
cur_index = i + self.start_index
|
||||
|
||||
# Adjust the trailing addr display length based on available room
|
||||
# (the index number will push it out on each order of magnitude)
|
||||
if cur_index < 10:
|
||||
end_digits = -6
|
||||
elif cur_index < 100:
|
||||
end_digits = -5
|
||||
else:
|
||||
end_digits = -4
|
||||
button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}")
|
||||
|
||||
button_data.append((_("Next {}").format(addrs_per_screen), None, None, None, SeedSignerIconConstants.CHEVRON_RIGHT))
|
||||
|
||||
selected_menu_num = self.run_screen(
|
||||
ButtonListScreen,
|
||||
title=_("Receive Addrs") if not self.is_change else _("Change Addrs"),
|
||||
button_data=button_data,
|
||||
button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME,
|
||||
button_font_size=GUIConstants.get_button_font_size() + 4,
|
||||
is_button_text_centered=False,
|
||||
is_bottom_list=True,
|
||||
selected_button=self.selected_button_index,
|
||||
scroll_y_initial_offset=self.initial_scroll,
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
@@ -656,7 +700,7 @@ class ToolsAddressExplorerAddressListView(View):
|
||||
return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index + addrs_per_screen))
|
||||
|
||||
# Preserve the list's current scroll so we can return to the same spot
|
||||
initial_scroll = screen.buttons[0].scroll_y
|
||||
initial_scroll = self.screen.buttons[0].scroll_y
|
||||
|
||||
index = selected_menu_num + self.start_index
|
||||
return Destination(ToolsAddressExplorerAddressView, view_args=dict(index=index, address=addresses[selected_menu_num], is_change=self.is_change, start_index=self.start_index, parent_initial_scroll=initial_scroll), skip_current_view=True)
|
||||
@@ -674,10 +718,12 @@ class ToolsAddressExplorerAddressView(View):
|
||||
|
||||
|
||||
def run(self):
|
||||
from seedsigner.gui.screens.screen import QRDisplayScreen
|
||||
qr_encoder = EncodeQR(qr_type=QRType.BITCOIN_ADDRESS, bitcoin_address=self.address)
|
||||
QRDisplayScreen(
|
||||
self.run_screen(
|
||||
QRDisplayScreen,
|
||||
qr_encoder=qr_encoder,
|
||||
).display()
|
||||
)
|
||||
|
||||
# Exiting/Cancelling the QR display screen always returns to the list
|
||||
return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index, selected_button_index=self.index - self.start_index, initial_scroll=self.parent_initial_scroll), skip_current_view=True)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from dataclasses import dataclass
|
||||
from gettext import gettext as _
|
||||
from typing import List
|
||||
from typing import Type
|
||||
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants
|
||||
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen
|
||||
from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerIconConstants
|
||||
from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON
|
||||
from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen
|
||||
from seedsigner.models.settings import Settings, SettingsConstants
|
||||
from seedsigner.models.settings_definition import SettingsDefinition
|
||||
from seedsigner.models.threads import BaseThread
|
||||
from seedsigner.models import Settings
|
||||
|
||||
|
||||
class BackStackView:
|
||||
@@ -43,11 +44,15 @@ class BackStackView:
|
||||
"Cancel" - End task and return to entry point (destructive)
|
||||
"""
|
||||
class View:
|
||||
def __init__(self) -> None:
|
||||
def _initialize(self):
|
||||
"""
|
||||
Whether the View is a regular class initialized by __init__() or a dataclass
|
||||
initialized by __post_init__(), this method will be called to set up the View's
|
||||
instance variables.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui import Renderer
|
||||
from seedsigner.models import Settings
|
||||
|
||||
self.controller: Controller = Controller.get_instance()
|
||||
self.settings = Settings.get_instance()
|
||||
@@ -57,14 +62,57 @@ class View:
|
||||
self.canvas_width = self.renderer.canvas_width
|
||||
self.canvas_height = self.renderer.canvas_height
|
||||
|
||||
self.buttons = self.controller.buttons
|
||||
self.screen = None
|
||||
|
||||
self._redirect: 'Destination' = None
|
||||
|
||||
|
||||
def run(self, **kwargs):
|
||||
if hasattr(self, "screen"):
|
||||
self.screen.display()
|
||||
else:
|
||||
raise Exception("Must implement in the child class")
|
||||
def __init__(self):
|
||||
self._initialize()
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
self._initialize()
|
||||
|
||||
|
||||
@property
|
||||
def has_redirect(self) -> bool:
|
||||
if not hasattr(self, '_redirect'):
|
||||
# Easy for a View to forget to call super().__init__()
|
||||
raise Exception(f"{self.__class__.__name__} did not call super().__init__()")
|
||||
return self._redirect is not None
|
||||
|
||||
|
||||
def set_redirect(self, destination: 'Destination'):
|
||||
"""
|
||||
Enables early `__init__()` / `__post_init__()` logic to redirect away from the
|
||||
current View.
|
||||
|
||||
Set a redirect Destination and then immediately `return` to exit `__init__()` or
|
||||
`__post_init__()`. When the `Destination.run()` is called, it will see the redirect
|
||||
and immediately return that new Destination to the Controller without running
|
||||
the View's `run()`.
|
||||
"""
|
||||
# Always insure skip_current_view is set for a redirect
|
||||
destination.skip_current_view = True
|
||||
self._redirect = destination
|
||||
|
||||
|
||||
def get_redirect(self) -> 'Destination':
|
||||
return self._redirect
|
||||
|
||||
|
||||
def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str:
|
||||
"""
|
||||
Instantiates the provided Screen_cls and runs its interactive display.
|
||||
Returns the user's input upon completion.
|
||||
"""
|
||||
self.screen = Screen_cls(**kwargs)
|
||||
return self.screen.display()
|
||||
|
||||
|
||||
def run(self, **kwargs) -> 'Destination':
|
||||
raise Exception("Must implement in the child class")
|
||||
|
||||
|
||||
|
||||
@@ -74,10 +122,10 @@ class Destination:
|
||||
Basic struct to pass back to the Controller to tell it which View the user should
|
||||
be presented with next.
|
||||
"""
|
||||
View_cls: View # The target View to route to
|
||||
view_args: dict = None # The input args required to instantiate the target View
|
||||
skip_current_view: bool = False # The current View is just forwarding; omit current View from history
|
||||
clear_history: bool = False # Optionally clears the back_stack to prevent "back"
|
||||
View_cls: Type[View] # The target View to route to
|
||||
view_args: dict = None # The input args required to instantiate the target View
|
||||
skip_current_view: bool = False # The current View is just forwarding; omit current View from history
|
||||
clear_history: bool = False # Optionally clears the back_stack to prevent "back"
|
||||
|
||||
|
||||
def __repr__(self):
|
||||
@@ -94,12 +142,24 @@ class Destination:
|
||||
return out
|
||||
|
||||
|
||||
def run(self):
|
||||
def _instantiate_view(self):
|
||||
if not self.view_args:
|
||||
# Can't unpack (**) None so we replace with an empty dict
|
||||
self.view_args = {}
|
||||
# Instantiate the `View_cls` and run() it with the `view_args` dict
|
||||
return self.View_cls(**self.view_args).run()
|
||||
|
||||
# Instantiate the `View_cls` with the `view_args` dict
|
||||
self.view = self.View_cls(**self.view_args)
|
||||
|
||||
|
||||
def _run_view(self):
|
||||
if self.view.has_redirect:
|
||||
return self.view.get_redirect()
|
||||
return self.view.run()
|
||||
|
||||
|
||||
def run(self):
|
||||
self._instantiate_view()
|
||||
return self._run_view()
|
||||
|
||||
|
||||
def __eq__(self, obj):
|
||||
@@ -122,53 +182,67 @@ class Destination:
|
||||
#
|
||||
#########################################################################################
|
||||
class MainMenuView(View):
|
||||
def run(self):
|
||||
from .seed_views import SeedsMenuView
|
||||
from .settings_views import SettingsMenuView
|
||||
from .scan_views import ScanView
|
||||
from .tools_views import ToolsMenuView
|
||||
from seedsigner.gui.screens import LargeButtonScreen
|
||||
menu_items = [
|
||||
((_("Scan"), FontAwesomeIconConstants.QRCODE), ScanView),
|
||||
((_("Seeds"), FontAwesomeIconConstants.KEY), SeedsMenuView),
|
||||
((_("Tools"), FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView),
|
||||
((_("Settings"), FontAwesomeIconConstants.GEAR), SettingsMenuView),
|
||||
]
|
||||
SCAN = ("Scan", SeedSignerIconConstants.SCAN)
|
||||
SEEDS = ("Seeds", SeedSignerIconConstants.SEEDS)
|
||||
TOOLS = ("Tools", SeedSignerIconConstants.TOOLS)
|
||||
SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS)
|
||||
|
||||
screen = LargeButtonScreen(
|
||||
def run(self):
|
||||
self.SCAN = ("Scan", SeedSignerIconConstants.SCAN)
|
||||
self.SEEDS = ("Seeds", SeedSignerIconConstants.SEEDS)
|
||||
self.TOOLS = ("Tools", SeedSignerIconConstants.TOOLS)
|
||||
self.SETTINGS = ("Settings", SeedSignerIconConstants.SETTINGS)
|
||||
from seedsigner.gui.screens.screen import MainMenuScreen
|
||||
button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS]
|
||||
selected_menu_num = self.run_screen(
|
||||
MainMenuScreen,
|
||||
title=_("Home"),
|
||||
title_font_size=26,
|
||||
button_data=[entry[0] for entry in menu_items],
|
||||
show_back_button=False,
|
||||
show_power_button=True,
|
||||
button_data=button_data,
|
||||
)
|
||||
selected_menu_num = screen.display()
|
||||
|
||||
if selected_menu_num == RET_CODE__POWER_BUTTON:
|
||||
return Destination(PowerOptionsView)
|
||||
|
||||
return Destination(menu_items[selected_menu_num][1])
|
||||
if button_data[selected_menu_num] == self.SCAN:
|
||||
from seedsigner.views.scan_views import ScanView
|
||||
return Destination(ScanView)
|
||||
|
||||
elif button_data[selected_menu_num] == self.SEEDS:
|
||||
from seedsigner.views.seed_views import SeedsMenuView
|
||||
return Destination(SeedsMenuView)
|
||||
|
||||
elif button_data[selected_menu_num] == self.TOOLS:
|
||||
from seedsigner.views.tools_views import ToolsMenuView
|
||||
return Destination(ToolsMenuView)
|
||||
|
||||
elif button_data[selected_menu_num] == self.SETTINGS:
|
||||
from seedsigner.views.settings_views import SettingsMenuView
|
||||
return Destination(SettingsMenuView)
|
||||
|
||||
|
||||
|
||||
class PowerOptionsView(View):
|
||||
RESET = ("Restart", SeedSignerIconConstants.RESTART)
|
||||
POWER_OFF = ("Power Off", SeedSignerIconConstants.POWER)
|
||||
|
||||
def run(self):
|
||||
RESET = (_("Restart"), FontAwesomeIconConstants.ROTATE_RIGHT)
|
||||
POWER_OFF = (_("Power Off"), FontAwesomeIconConstants.POWER_OFF)
|
||||
button_data = [RESET, POWER_OFF]
|
||||
selected_menu_num = LargeButtonScreen(
|
||||
self.RESET = ("Restart", SeedSignerIconConstants.RESTART)
|
||||
self.POWER_OFF = ("Power Off", SeedSignerIconConstants.POWER)
|
||||
button_data = [self.RESET, self.POWER_OFF]
|
||||
selected_menu_num = self.run_screen(
|
||||
LargeButtonScreen,
|
||||
title=_("Reset / Power"),
|
||||
show_back_button=True,
|
||||
button_data=button_data
|
||||
).display()
|
||||
)
|
||||
|
||||
if selected_menu_num == RET_CODE__BACK_BUTTON:
|
||||
return Destination(BackStackView)
|
||||
|
||||
elif button_data[selected_menu_num] == RESET:
|
||||
elif button_data[selected_menu_num] == self.RESET:
|
||||
return Destination(RestartView)
|
||||
|
||||
elif button_data[selected_menu_num] == POWER_OFF:
|
||||
elif button_data[selected_menu_num] == self.POWER_OFF:
|
||||
return Destination(PowerOffView)
|
||||
|
||||
|
||||
@@ -177,7 +251,7 @@ class RestartView(View):
|
||||
def run(self):
|
||||
thread = RestartView.DoResetThread()
|
||||
thread.start()
|
||||
ResetScreen().display()
|
||||
self.run_screen(ResetScreen)
|
||||
|
||||
|
||||
class DoResetThread(BaseThread):
|
||||
@@ -201,12 +275,12 @@ class RestartView(View):
|
||||
class PowerOffView(View):
|
||||
def run(self):
|
||||
if Settings.HOSTNAME == Settings.SEEDSIGNER_OS:
|
||||
PowerOffNotRequiredScreen().display()
|
||||
self.run_screen(PowerOffNotRequiredScreen)
|
||||
return Destination(BackStackView)
|
||||
else:
|
||||
thread = PowerOffView.PowerOffThread()
|
||||
thread.start()
|
||||
PowerOffScreen().display()
|
||||
self.run_screen(PowerOffScreen)
|
||||
|
||||
|
||||
class PowerOffThread(BaseThread):
|
||||
@@ -223,29 +297,127 @@ class NotYetImplementedView(View):
|
||||
Temporary View to use during dev.
|
||||
"""
|
||||
def run(self):
|
||||
WarningScreen(
|
||||
self.run_screen(
|
||||
WarningScreen,
|
||||
title=_("Work In Progress"),
|
||||
status_headline=_("Not Yet Implemented"),
|
||||
text=_("This is still on our to-do list!"),
|
||||
button_data=[_("Back to Main Menu")],
|
||||
).display()
|
||||
)
|
||||
|
||||
return Destination(MainMenuView)
|
||||
|
||||
|
||||
|
||||
class UnhandledExceptionView(View):
|
||||
def __init__(self, error: List[str]):
|
||||
self.error = error
|
||||
@dataclass
|
||||
class ErrorView(View):
|
||||
title: str = "Error"
|
||||
show_back_button: bool = True
|
||||
status_headline: str = None
|
||||
text: str = None
|
||||
button_text: str = None
|
||||
next_destination: Destination = Destination(MainMenuView, clear_history=True)
|
||||
|
||||
|
||||
def run(self):
|
||||
DireWarningScreen(
|
||||
self.title = _("Error"),
|
||||
self.run_screen(
|
||||
WarningScreen,
|
||||
title=self.title,
|
||||
status_headline=self.status_headline,
|
||||
text=self.text,
|
||||
button_data=[self.button_text],
|
||||
show_back_button=self.show_back_button,
|
||||
)
|
||||
|
||||
return self.next_destination
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkMismatchErrorView(ErrorView):
|
||||
title: str = "Network Mismatch"
|
||||
show_back_button: bool = False
|
||||
button_text: str = "Change Setting"
|
||||
next_destination: Destination = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not self.text:
|
||||
self.text = f"Current network setting ({self.settings.get_value_display_name(SettingsConstants.SETTING__NETWORK)}) doesn't match current action."
|
||||
|
||||
if not self.next_destination:
|
||||
from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
|
||||
self.next_destination = Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=SettingsConstants.SETTING__NETWORK), clear_history=True)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnhandledExceptionView(View):
|
||||
error: list[str]
|
||||
|
||||
|
||||
def run(self):
|
||||
self.run_screen(
|
||||
DireWarningScreen,
|
||||
title=_("System Error"),
|
||||
status_headline=self.error[0],
|
||||
text=self.error[1] + "\n" + self.error[2],
|
||||
button_data=[_("OK")],
|
||||
show_back_button=False,
|
||||
).display()
|
||||
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
|
||||
)
|
||||
|
||||
return Destination(MainMenuView, clear_history=True)
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class OptionDisabledView(View):
|
||||
UPDATE_SETTING = "Update Setting"
|
||||
DONE = "Done"
|
||||
settings_attr: str
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
self.settings_entry = SettingsDefinition.get_settings_entry(self.settings_attr)
|
||||
self.error_msg = f"\"{self.settings_entry.display_name}\" is currently disabled in Settings."
|
||||
|
||||
|
||||
def run(self):
|
||||
button_data = [self.UPDATE_SETTING, self.DONE]
|
||||
selected_menu_num = self.run_screen(
|
||||
WarningScreen,
|
||||
title="Option Disabled",
|
||||
status_headline=None,
|
||||
text=self.error_msg,
|
||||
button_data=button_data,
|
||||
show_back_button=False,
|
||||
allow_text_overflow=True, # Fit what we can, let the rest go off the edges
|
||||
)
|
||||
|
||||
if button_data[selected_menu_num] == self.UPDATE_SETTING:
|
||||
from seedsigner.views.settings_views import SettingsEntryUpdateSelectionView
|
||||
return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_attr), clear_history=True)
|
||||
else:
|
||||
return Destination(MainMenuView, clear_history=True)
|
||||
|
||||
|
||||
|
||||
class RemoveMicroSDWarningView(View):
|
||||
"""
|
||||
Warning to remove the microsd
|
||||
"""
|
||||
def __init__(self, next_view: View):
|
||||
super().__init__()
|
||||
self.next_view = next_view
|
||||
|
||||
def run(self):
|
||||
self.run_screen(
|
||||
WarningScreen,
|
||||
title="Security Tip",
|
||||
status_icon_name=FontAwesomeIconConstants.SDCARD,
|
||||
status_headline="",
|
||||
text="For maximum security,\nremove the MicroSD card\nbefore continuing.",
|
||||
show_back_button=False,
|
||||
button_data=["Continue"],
|
||||
)
|
||||
|
||||
return Destination(self.next_view, clear_history=True)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
# Running Tests
|
||||
|
||||
You must install the test-runner dependencies:
|
||||
```
|
||||
The tests are designed to be run on non-Raspi hardware.
|
||||
|
||||
On your testing machine you'll have to install:
|
||||
```bash
|
||||
# general dependencies
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
# test suite dependencies
|
||||
pip3 install -r tests/requirements.txt
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from mock import MagicMock, Mock, patch
|
||||
from typing import Callable
|
||||
|
||||
# Prevent importing modules w/Raspi hardware dependencies.
|
||||
# These must precede any SeedSigner imports.
|
||||
sys.modules['seedsigner.gui.renderer'] = MagicMock()
|
||||
sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock()
|
||||
sys.modules['seedsigner.gui.toast'] = MagicMock()
|
||||
sys.modules['seedsigner.views.screensaver'] = MagicMock()
|
||||
sys.modules['seedsigner.hardware.buttons'] = MagicMock()
|
||||
sys.modules['seedsigner.hardware.camera'] = MagicMock()
|
||||
|
||||
from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
from seedsigner.models.settings import Settings
|
||||
from seedsigner.views.view import Destination, MainMenuView, View
|
||||
|
||||
|
||||
|
||||
|
||||
class BaseTest:
|
||||
|
||||
class MockMicroSD(Mock):
|
||||
"""
|
||||
A test suite-friendly replacement for `MicroSD` that gives a test explicit
|
||||
control over the reported state of the SD card.
|
||||
"""
|
||||
# Tests are free to directly manipulate this attribute as needed (it's reset to
|
||||
# True before each test in `BaseTest.setup_method()`).
|
||||
is_inserted: bool = True
|
||||
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
# Ensure there are no on-disk artifacts after running tests.
|
||||
Settings.SETTINGS_FILENAME = "settings-test.json"
|
||||
|
||||
# Mock out the loading screen so it can't spawn. View classes must import locally!
|
||||
patch('seedsigner.gui.screens.screen.LoadingScreenThread').start()
|
||||
|
||||
# Instantiate the mocked MicroSD; hold on to the instance so tests can manipulate
|
||||
# it later.
|
||||
cls.mock_microsd = BaseTest.MockMicroSD()
|
||||
|
||||
# And mock it over `MicroSD`'s instance
|
||||
MicroSD.get_instance = Mock(return_value=cls.mock_microsd)
|
||||
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
pass
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset_settings(cls):
|
||||
""" Wipe and re-initialize the Settings singleton """
|
||||
Settings._instance = None
|
||||
BaseTest.remove_settings()
|
||||
|
||||
|
||||
@classmethod
|
||||
def remove_settings(cls):
|
||||
""" If settings were written to disk, delete """
|
||||
import os
|
||||
try:
|
||||
if os.path.exists(Settings.SETTINGS_FILENAME):
|
||||
os.remove(Settings.SETTINGS_FILENAME)
|
||||
except:
|
||||
print(f"{Settings.SETTINGS_FILENAME} could not be removed")
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset_controller(cls):
|
||||
""" Wipe and re-initialize the Controller singleton """
|
||||
Controller._instance = None
|
||||
Controller.configure_instance()
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
""" Guarantee a clean/default Controller, Settings, & MicroSD state for each test case """
|
||||
BaseTest.reset_controller()
|
||||
BaseTest.reset_settings()
|
||||
self.controller = Controller.get_instance()
|
||||
self.settings = Settings.get_instance()
|
||||
self.mock_microsd.is_inserted = True
|
||||
|
||||
|
||||
def teardown_method(self):
|
||||
BaseTest.remove_settings()
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowStep:
|
||||
"""
|
||||
Trivial helper class to express FlowTest sequences below.
|
||||
|
||||
* expected_view: verify that the current step in the sequence instantiates the right View.
|
||||
* before_run: function that takes a View instance as an arg and modifies it before running the View.
|
||||
* screen_return_value: mocked Screen interaction result: raw return value as if from the Screen.
|
||||
* button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option.
|
||||
* is_redirect: expects the Destination to specify `skip_current_view=True`.
|
||||
"""
|
||||
expected_view: type[View] = None
|
||||
before_run: Callable[[View], None] = None
|
||||
screen_return_value: int | str = None
|
||||
button_data_selection: str | tuple = None
|
||||
is_redirect: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if self.screen_return_value is not None and self.button_data_selection is not None:
|
||||
raise Exception("Can't specify both `screen_return_value` and `button_data_selection`")
|
||||
|
||||
|
||||
|
||||
class FlowTestInvalidButtonDataSelectionException(FlowBasedTestException):
|
||||
""" The FlowStep's button_data_selection value was not found in the View's button_data """
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class FlowTestUnexpectedViewException(FlowBasedTestException):
|
||||
""" The Controller's current View did not match the expected View in the FlowStep sequence """
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class FlowTestRunScreenNotExecutedException(FlowBasedTestException):
|
||||
""" The View's run_screen() method was not called but the FlowStep expected it to need user input """
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class FlowTest(BaseTest):
|
||||
""" Base class for any tests that do flow-based testing """
|
||||
|
||||
def stop_test(self):
|
||||
""" Raise our special Exception to stop the test """
|
||||
raise StopFlowBasedTest()
|
||||
|
||||
|
||||
def run_sequence(self, sequence: list[FlowStep], initial_destination_view_args: dict = None) -> None:
|
||||
"""
|
||||
Run a pre-set sequence of Views w/manually-specified return values in order to test
|
||||
the Controller's flow control logic and the routing from View to View.
|
||||
"""
|
||||
with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_run_view:
|
||||
with patch("seedsigner.views.view.View.run_screen", autospec=True) as mock_run_screen:
|
||||
def run_view(destination: Destination, *args, **kwargs):
|
||||
""" Replaces Destination._run_view() """
|
||||
if len(sequence) == 0:
|
||||
self.stop_test()
|
||||
|
||||
cur_flow_step = sequence[0]
|
||||
|
||||
# Verify that the View class specified in the test sequence matches the
|
||||
# View class that is being run.
|
||||
if destination.View_cls != cur_flow_step.expected_view:
|
||||
raise FlowTestUnexpectedViewException(f"Expected {cur_flow_step.expected_view}, got {destination.View_cls}")
|
||||
|
||||
# Run the optional pre-run function to modify the View.
|
||||
if cur_flow_step.before_run:
|
||||
cur_flow_step.before_run(destination.view)
|
||||
|
||||
if cur_flow_step.is_redirect:
|
||||
# The current View is going to auto-redirect without calling run_screen(),
|
||||
# so we need to remove the current step from the sequence before the
|
||||
# View.run() call below.
|
||||
sequence.pop(0)
|
||||
|
||||
if destination.view.has_redirect:
|
||||
# TODO: Migrate all View redirects to use `View.set_redirect()`
|
||||
# in their `__init__()` rather than `run()` and then refactor
|
||||
# here to explicitly require `has_redirect` to be True.
|
||||
# For now: Support the newer `set_redirect()` routing while
|
||||
# still letting redirects get returned by `View.run()` further
|
||||
# below.
|
||||
return destination.view.get_redirect()
|
||||
|
||||
# Some Views reach into their Screen's variables directly (e.g.
|
||||
# Screen.buttons to preserve the scroll position), so we need to mock out the
|
||||
# Screen instance that is created by the View.
|
||||
destination.view.screen = MagicMock()
|
||||
|
||||
prev_mock_run_screen_call_count = mock_run_screen.call_count
|
||||
|
||||
# Run the View (with our mocked run_screen) and get the next Destination that results
|
||||
destination = destination.view.run()
|
||||
|
||||
if (cur_flow_step.button_data_selection or cur_flow_step.screen_return_value is not None) and mock_run_screen.call_count == prev_mock_run_screen_call_count:
|
||||
# The FlowStep was expecting some kind of user interaction, but the View
|
||||
# never called run_screen().
|
||||
raise FlowTestRunScreenNotExecutedException(f"View.run_screen() was not run for {destination.View_cls.__name__}")
|
||||
|
||||
return destination
|
||||
|
||||
def run_screen(view: View, *args, **kwargs):
|
||||
""" Replaces View.run_screen() """
|
||||
# Return the return value specified in the test sequence and
|
||||
# remove the completed test step from the sequence.
|
||||
flow_step = sequence.pop(0)
|
||||
|
||||
if flow_step.button_data_selection:
|
||||
# We're mocking out the View.run_screen() method, so we'll get all of the
|
||||
# input args that are normally passed into the Screen.run() method,
|
||||
# including the button_data kwarg.
|
||||
if "button_data" in kwargs:
|
||||
if flow_step.button_data_selection not in kwargs.get("button_data") and flow_step.button_data_selection not in [RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON]:
|
||||
raise FlowTestInvalidButtonDataSelectionException(f"'{flow_step.button_data_selection}' not found in button_data: {kwargs.get('button_data')}")
|
||||
return kwargs.get("button_data").index(flow_step.button_data_selection)
|
||||
else:
|
||||
raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()")
|
||||
|
||||
elif type(flow_step.screen_return_value) in [StopFlowBasedTest, FlowBasedTestException]:
|
||||
raise flow_step.screen_return_value
|
||||
|
||||
return flow_step.screen_return_value
|
||||
|
||||
# Mock out the Destination._run_view() method so we can verify the View class
|
||||
# that is specified in the test sequence and then run the View.
|
||||
mock_run_view.side_effect = run_view
|
||||
|
||||
# Mock out the View.run_screen() method so we can provide the
|
||||
# return value that is specified in the test sequence.
|
||||
mock_run_screen.side_effect = run_screen
|
||||
|
||||
# Start the Controller with the first View_cls specified in the test sequence
|
||||
if sequence[0].expected_view != MainMenuView:
|
||||
initial_destination = Destination(sequence[0].expected_view, view_args=initial_destination_view_args)
|
||||
else:
|
||||
initial_destination = None
|
||||
|
||||
# Start the Controller and run the sequence
|
||||
Controller.get_instance().start(initial_destination=initial_destination)
|
||||
@@ -3,6 +3,7 @@
|
||||
From the project root, run:
|
||||
```
|
||||
pytest tests/screenshot_generator/generator.py --locale en
|
||||
pytest tests/screenshot_generator/generator.py
|
||||
```
|
||||
|
||||
By default it writes the screenshots to a dir that's parallel to the project root: `seedsigner-screenshots/<locale>`.
|
||||
Writes the screenshots to a dir in the project root: `seedsigner-screenshots`.
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
import embit
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import pathlib
|
||||
import pytest
|
||||
import shutil
|
||||
from mock import Mock, patch
|
||||
|
||||
from mock import Mock, patch, MagicMock
|
||||
|
||||
from seedsigner.helpers import embit_utils
|
||||
from seedsigner.models.settings import Settings
|
||||
|
||||
# Prevent importing modules w/Raspi hardware dependencies.
|
||||
# These must precede any SeedSigner imports.
|
||||
sys.modules['seedsigner.hardware.ST7789'] = MagicMock()
|
||||
sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock()
|
||||
sys.modules['seedsigner.views.screensaver'] = MagicMock()
|
||||
sys.modules['RPi'] = MagicMock()
|
||||
sys.modules['RPi.GPIO'] = MagicMock()
|
||||
sys.modules['seedsigner.hardware.camera'] = MagicMock()
|
||||
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
|
||||
|
||||
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui.renderer import Renderer
|
||||
from seedsigner.gui.toast import BaseToastOverlayManagerThread, RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread
|
||||
from seedsigner.hardware.buttons import HardwareButtons
|
||||
from seedsigner.hardware.camera import Camera
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
from seedsigner.models.decode_qr import DecodeQR
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings import Settings
|
||||
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
|
||||
from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, psbt_views, scan_views, seed_views,
|
||||
settings_views, tools_views)
|
||||
from seedsigner.views.view import View
|
||||
from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, NotYetImplementedView, UnhandledExceptionView,
|
||||
psbt_views, seed_views, settings_views, tools_views)
|
||||
from seedsigner.views.view import ErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView, View
|
||||
|
||||
from .utils import ScreenshotComplete, ScreenshotRenderer
|
||||
|
||||
# Ignore Pillow 10 DeprecationWarnings "use getbbox() or getlength() instead of getsize()"
|
||||
import warnings; warnings.warn = lambda *args, **kwargs: None
|
||||
|
||||
|
||||
def test_generate_screenshots(target_locale):
|
||||
@@ -30,12 +50,8 @@ def test_generate_screenshots(target_locale):
|
||||
When the `Renderer` instance is needed, we patch in our own test-only
|
||||
`ScreenshotRenderer`.
|
||||
"""
|
||||
# Disable hardware dependencies by essentially wiping out this class
|
||||
HardwareButtons.get_instance = Mock()
|
||||
Camera.get_instance = Mock()
|
||||
|
||||
# Prep the ScreenshotRenderer that will be patched over the normal Renderer
|
||||
screenshot_root = "/home/pi/seedsigner-screenshots"
|
||||
screenshot_root = os.path.join(os.getcwd(), "seedsigner-screenshots")
|
||||
ScreenshotRenderer.configure_instance()
|
||||
screenshot_renderer: ScreenshotRenderer = ScreenshotRenderer.get_instance()
|
||||
|
||||
@@ -43,6 +59,9 @@ def test_generate_screenshots(target_locale):
|
||||
Renderer.configure_instance = Mock()
|
||||
Renderer.get_instance = Mock(return_value=screenshot_renderer)
|
||||
|
||||
# Additional mocks needed
|
||||
PowerOffView.PowerOffThread = Mock() # Don't let this View actually send the `shutdown` command!
|
||||
|
||||
controller = Controller.get_instance()
|
||||
|
||||
# Set up some test data that we'll need in the `Controller` for certain Views
|
||||
@@ -55,6 +74,13 @@ def test_generate_screenshots(target_locale):
|
||||
controller.storage.seeds.append(seed_12)
|
||||
controller.storage.seeds.append(seed_12b)
|
||||
controller.storage.set_pending_seed(seed_24)
|
||||
UnhandledExceptionViewFood = ["IndexError", "line 1, in some_buggy_code.py", "list index out of range"]
|
||||
|
||||
# Pending mnemonic for ToolsCalcFinalWordShowFinalWordView
|
||||
controller.storage.init_pending_mnemonic(num_words=12)
|
||||
for i, word in enumerate(mnemonic_12[:11]):
|
||||
controller.storage.update_pending_mnemonic(word=word, index=i)
|
||||
controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet)
|
||||
|
||||
# Load a PSBT into memory
|
||||
BASE64_PSBT_1 = """cHNidP8BAP06AQIAAAAC5l4E3oEjI+H0im8t/K2nLmF5iJFdKEiuQs8ESveWJKcAAAAAAP3///8iBZMRhYIq4s/LmnTmKBi79M8ITirmsbO++63evK4utwAAAAAA/f///wZYQuoDAAAAACIAIAW5jm3UnC5fyjKCUZ8LTzjENtb/ioRTaBMXeSXsB3n+bK2fCgAAAAAWABReJY7akT1+d+jx475yBRWORdBd7VxbUgUAAAAAFgAU4wj9I/jB3GjNQudNZAca+7g9R16iWtYOAAAAABYAFIotPApLZlfscg8f3ppKqO3qA5nv7BnMFAAAAAAiACAs6SGc8qv4FwuNl0G0SpMZG8ODUEk5RXiWUcuzzw5iaRSfAhMAAAAAIgAgW0f5QxQIgVCGQqKzsvfkXZjUxdFop5sfez6Pt8mUbmZ1AgAAAAEAkgIAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/////BQIRAgEB/////wJAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAAAAAAAAAAAmaiSqIant4vYcP3HR3v0/qZnfo2lTdVxpBol5mWK0i+vYNpdOjPkAAAAAAQErQL5AJQAAAAAiACCET6KNi75K8K4a2BYS4ZT+N4s8WwOBKOmOohRYkGHV0QEFR1EhArGhNdUqlR4BAOLGTMrY2ZJYTQNRudp7fU7i8crRJqgEIQNDxn7PjUzvsP6KYw4s7dmoZE0qO1K6MaM+2ScRZ7hyxFKuIgYCsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQcc8XaCjAAAIABAACAAAAAgAIAAIAAAAAAAwAAACIGA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEHCK94akwAACAAQAAgAAAAIACAACAAAAAAAMAAAAAAQCSAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP////8FAhACAQH/////AkC+QCUAAAAAIgAghE+ijYu+SvCuGtgWEuGU/jeLPFsDgSjpjqIUWJBh1dEAAAAAAAAAACZqJKohqe3i9hw/cdHe/T+pmd+jaVN1XGkGiXmZYrSL69g2l06M+QAAAAABAStAvkAlAAAAACIAIIRPoo2LvkrwrhrYFhLhlP43izxbA4Eo6Y6iFFiQYdXRAQVHUSECsaE11SqVHgEA4sZMytjZklhNA1G52nt9TuLxytEmqAQhA0PGfs+NTO+w/opjDizt2ahkTSo7Uroxoz7ZJxFnuHLEUq4iBgKxoTXVKpUeAQDixkzK2NmSWE0DUbnae31O4vHK0SaoBBxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAADAAAAIgYDQ8Z+z41M77D+imMOLO3ZqGRNKjtSujGjPtknEWe4csQcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAAAwAAAAABAUdRIQJ5XLCBS0hdo4NANq4lNhimzhyHj7dvObmPAwNj8L2xASEC9mwwoH28/WHnxbb6z05sJ/lHuvrLs/wOooHgFn5ulI1SriICAnlcsIFLSF2jg0A2riU2GKbOHIePt285uY8DA2PwvbEBHCK94akwAACAAQAAgAAAAIACAACAAQAAAAEAAAAiAgL2bDCgfbz9YefFtvrPTmwn+Ue6+suz/A6igeAWfm6UjRxzxdoKMAAAgAEAAIAAAACAAgAAgAEAAAABAAAAAAAAAAEBR1EhAgpbWcEh7rgvRE5UaCcqzWL/TR1B/DS8UeZsKVEvuKLrIQOwLg0emiQbbxafIh69Xjtpj4eclsMhKq1y/7vYDdE7LVKuIgICCltZwSHuuC9ETlRoJyrNYv9NHUH8NLxR5mwpUS+4ouscc8XaCjAAAIABAACAAAAAgAIAAIAAAAAABQAAACICA7AuDR6aJBtvFp8iHr1eO2mPh5yWwyEqrXL/u9gN0TstHCK94akwAACAAQAAgAAAAIACAACAAAAAAAUAAAAAAQFHUSECk50GLh/YhZaLJkDq/dugU3H/WvE6rTgQuY6N57pI4ykhA/H8MdLVP9SA/Hg8l3hvibSaC1bCBzwz7kTW+rsEZ8uFUq4iAgKTnQYuH9iFlosmQOr926BTcf9a8TqtOBC5jo3nukjjKRxzxdoKMAAAgAEAAIAAAACAAgAAgAAAAAAGAAAAIgID8fwx0tU/1ID8eDyXeG+JtJoLVsIHPDPuRNb6uwRny4UcIr3hqTAAAIABAACAAAAAgAIAAIAAAAAABgAAAAA="""
|
||||
@@ -72,24 +98,20 @@ def test_generate_screenshots(target_locale):
|
||||
with open(messages_source_path, 'r') as messages_source_file:
|
||||
num_source_messages = messages_source_file.read().count("msgid \"") - 1
|
||||
|
||||
def screencap_view(view_cls: View, view_name: str, view_args: dict={}):
|
||||
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
|
||||
try:
|
||||
print(f"Running {view_name}")
|
||||
view_cls(**view_args).run()
|
||||
except ScreenshotComplete:
|
||||
# Slightly hacky way to exit ScreenshotRenderer as expected
|
||||
pass
|
||||
print(f"Completed {view_name}")
|
||||
except Exception as e:
|
||||
# Something else went wrong
|
||||
print(repr(e))
|
||||
raise e
|
||||
|
||||
# Message signing data
|
||||
derivation_path = "m/84h/0h/0h/0/0"
|
||||
controller.sign_message_data = {
|
||||
"seed_num": 0,
|
||||
"derivation_path": derivation_path,
|
||||
"message": "I attest that I control this bitcoin address blah blah blah",
|
||||
"addr_format": embit_utils.parse_derivation_path(derivation_path)
|
||||
}
|
||||
|
||||
# Automatically populate all Settings options Views
|
||||
settings_views_list = []
|
||||
settings_views_list.append(settings_views.SettingsMenuView)
|
||||
#jdlcdl so we get a choice for transcribe seed qr format
|
||||
|
||||
# so we get a choice for transcribe seed qr format
|
||||
controller.settings.set_value(
|
||||
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
|
||||
value=SettingsConstants.OPTION__ENABLED
|
||||
@@ -99,17 +121,20 @@ def test_generate_screenshots(target_locale):
|
||||
continue
|
||||
|
||||
settings_views_list.append((settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
|
||||
settings_views_list.append(settings_views.IOTestView)
|
||||
settings_views_list.append(settings_views.DonateView)
|
||||
|
||||
|
||||
settingsqr_data_persistent = "settings::v1 name=Total_noob_mode persistent=E coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
|
||||
settingsqr_data_not_persistent = "settings::v1 name=Ephemeral_noob_mode persistent=D coords=spa,spd denom=thr network=M qr_density=M xpub_export=E sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E"
|
||||
|
||||
screenshot_sections = {
|
||||
"Main Menu Views": [
|
||||
MainMenuView,
|
||||
(MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_removed', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED)),
|
||||
(MainMenuView, {}, 'MainMenuView_SDCardStateChangeToast_inserted', SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED)),
|
||||
(MainMenuView, {}, 'MainMenuView_RemoveSDCardToast', RemoveSDCardToastManagerThread(activation_delay=0)),
|
||||
PowerOptionsView,
|
||||
RestartView,
|
||||
#main_menu_views.PowerOffView,
|
||||
(scan_views.SettingsUpdatedView, dict(config_name="Keith's Settings")),
|
||||
PowerOffView,
|
||||
],
|
||||
"Seed Views": [
|
||||
seed_views.SeedsMenuView,
|
||||
@@ -117,9 +142,9 @@ def test_generate_screenshots(target_locale):
|
||||
seed_views.SeedMnemonicEntryView,
|
||||
seed_views.SeedMnemonicInvalidView,
|
||||
seed_views.SeedFinalizeView,
|
||||
seed_views.SeedPassphraseWarningView,
|
||||
seed_views.SeedAddPassphraseView,
|
||||
seed_views.SeedReviewPassphraseView,
|
||||
|
||||
(seed_views.SeedOptionsView, dict(seed_num=0)),
|
||||
(seed_views.SeedBackupView, dict(seed_num=0)),
|
||||
(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
|
||||
@@ -128,11 +153,13 @@ def test_generate_screenshots(target_locale):
|
||||
(seed_views.SeedExportXpubCoordinatorView, dict(seed_num=0, sig_type="ss", script_type="nat")),
|
||||
(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", coordinator="spd", custom_derivation="")),
|
||||
(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", coordinator="bw", custom_derivation="")),
|
||||
#SeedExportXpubQRDisplayView,
|
||||
(seed_views.SeedWordsWarningView, dict(seed_num=0)),
|
||||
(seed_views.SeedWordsView, dict(seed_num=0)),
|
||||
(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), "SeedWordsView_2"),
|
||||
(seed_views.SeedBIP85ApplicationModeView, dict(seed_num=0)),
|
||||
(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)),
|
||||
(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)),
|
||||
(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)),
|
||||
(seed_views.SeedWordsBackupTestView, dict(seed_num=0)),
|
||||
(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="unlucky")),
|
||||
@@ -150,7 +177,6 @@ def test_generate_screenshots(target_locale):
|
||||
# Screenshot can't render live preview screens
|
||||
# (seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)),
|
||||
|
||||
#jdlcdl todo
|
||||
#(seed_views.AddressVerificationStartView, dict(address=, script_type="nat", network="M")),
|
||||
#seed_views.AddressVerificationSigTypeView,
|
||||
#seed_views.SeedSingleSigAddressVerificationSelectSeedView,
|
||||
@@ -160,9 +186,12 @@ def test_generate_screenshots(target_locale):
|
||||
seed_views.LoadMultisigWalletDescriptorView,
|
||||
seed_views.MultisigWalletDescriptorView,
|
||||
(seed_views.SeedDiscardView, dict(seed_num=0)),
|
||||
|
||||
seed_views.SeedSignMessageConfirmMessageView,
|
||||
seed_views.SeedSignMessageConfirmAddressView,
|
||||
],
|
||||
"PSBT Views": [
|
||||
psbt_views.PSBTSelectSeedView,
|
||||
psbt_views.PSBTSelectSeedView, # this will fail, be rerun below
|
||||
psbt_views.PSBTOverviewView,
|
||||
psbt_views.PSBTUnsupportedScriptTypeWarningView,
|
||||
psbt_views.PSBTNoChangeWarningView,
|
||||
@@ -176,28 +205,74 @@ def test_generate_screenshots(target_locale):
|
||||
(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_change"),
|
||||
(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), "PSBTAddressVerificationFailedView_multisig_selftransfer"),
|
||||
psbt_views.PSBTFinalizeView,
|
||||
#PSBTSignedQRDisplayView
|
||||
psbt_views.PSBTSigningErrorView,
|
||||
],
|
||||
"Tools Views": [
|
||||
tools_views.ToolsMenuView,
|
||||
#ToolsImageEntropyLivePreviewView
|
||||
#ToolsImageEntropyFinalImageView
|
||||
tools_views.ToolsImageEntropyMnemonicLengthView,
|
||||
tools_views.ToolsDiceEntropyMnemonicLengthView,
|
||||
(tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)),
|
||||
tools_views.ToolsCalcFinalWordNumWordsView,
|
||||
tools_views.ToolsCalcFinalWordFinalizePromptView,
|
||||
tools_views.ToolsCalcFinalWordCoinFlipsView,
|
||||
# jdlcdl todo
|
||||
#(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips=3)),
|
||||
(tools_views.ToolsCalcFinalWordShowFinalWordView, {}, "ToolsCalcFinalWordShowFinalWordView_pick_word"),
|
||||
(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), "ToolsCalcFinalWordShowFinalWordView_coin_flips"),
|
||||
#tools_views.ToolsCalcFinalWordDoneView,
|
||||
tools_views.ToolsAddressExplorerSelectSourceView,
|
||||
tools_views.ToolsAddressExplorerAddressTypeView,
|
||||
tools_views.ToolsAddressExplorerAddressListView,
|
||||
# jdlcdl todo
|
||||
#tools_views.ToolsAddressExplorerAddressView,
|
||||
],
|
||||
"Settings Views": settings_views_list,
|
||||
"Settings Views": settings_views_list + [
|
||||
settings_views.IOTestView,
|
||||
settings_views.DonateView,
|
||||
(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), "SettingsIngestSettingsQRView_persistent"),
|
||||
(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), "SettingsIngestSettingsQRView_not_persistent"),
|
||||
],
|
||||
"Misc Error Views": [
|
||||
NotYetImplementedView,
|
||||
(UnhandledExceptionView, dict(error=UnhandledExceptionViewFood)),
|
||||
#NetworkMismatchErrorView,
|
||||
#(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)),
|
||||
#(ErrorView, dict(
|
||||
# title="Error",
|
||||
# status_headline="Unknown QR Type",
|
||||
# text="QRCode is invalid or is a data format not yet supported.",
|
||||
# button_text="Back",
|
||||
#)),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def screencap_view(view_cls: View, view_name: str, view_args: dict={}, toast_thread: BaseToastOverlayManagerThread = None):
|
||||
screenshot_renderer.set_screenshot_filename(f"{view_name}.png")
|
||||
try:
|
||||
print(f"Running {view_name}")
|
||||
try:
|
||||
view_cls(**view_args).run()
|
||||
except ScreenshotComplete:
|
||||
if toast_thread is not None:
|
||||
controller.activate_toast(toast_thread)
|
||||
while controller.toast_notification_thread.is_alive():
|
||||
time.sleep(0.1)
|
||||
raise ScreenshotComplete()
|
||||
except ScreenshotComplete:
|
||||
# Slightly hacky way to exit ScreenshotRenderer as expected
|
||||
pass
|
||||
print(f"Completed {view_name}")
|
||||
except Exception as e:
|
||||
# Something else went wrong
|
||||
from traceback import print_exc
|
||||
print_exc()
|
||||
raise e
|
||||
finally:
|
||||
if toast_thread:
|
||||
toast_thread.stop()
|
||||
|
||||
|
||||
locales = []
|
||||
if target_locale is None:
|
||||
locales = SettingsConstants.ALL_LOCALES
|
||||
@@ -221,11 +296,14 @@ def test_generate_screenshots(target_locale):
|
||||
locale_translations = translation_file.read()
|
||||
num_locale_translations = locale_translations.count("msgid \"") - locale_translations.count("""msgstr ""\n\n""") - 1
|
||||
|
||||
locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n"
|
||||
locale_readme += f"[{display_name} messages.po catalog](messages.po)\n"
|
||||
if locale != "en":
|
||||
locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n"
|
||||
locale_readme += f"[{display_name} messages.po catalog](messages.po)\n"
|
||||
locale_readme += "---\n\n"
|
||||
|
||||
for section_name, screenshot_list in screenshot_sections.items():
|
||||
subdir = section_name.lower().replace(" ", "_")
|
||||
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, subdir))
|
||||
locale_readme += "\n\n---\n\n"
|
||||
locale_readme += f"## {section_name}\n\n"
|
||||
locale_readme += """<table style="border: 0;">"""
|
||||
@@ -237,21 +315,31 @@ def test_generate_screenshots(target_locale):
|
||||
view_name = view_cls.__name__
|
||||
elif len(screenshot) == 3:
|
||||
view_cls, view_args, view_name = screenshot
|
||||
elif len(screenshot) == 4:
|
||||
view_cls, view_args, view_name, toast_thread = screenshot
|
||||
else:
|
||||
view_cls = screenshot
|
||||
view_args = {}
|
||||
view_name = view_cls.__name__
|
||||
toast_thread = None
|
||||
|
||||
screencap_view(view_cls, view_name, view_args)
|
||||
locale_readme += """<table align="left" style="border: 1px solid gray;">"""
|
||||
locale_readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{view_name}.png"></td></tr>"""
|
||||
locale_readme += """</table>"""
|
||||
screencap_view(view_cls, view_name, view_args, toast_thread=toast_thread)
|
||||
locale_readme += """ <table align="left" style="border: 1px solid gray;">"""
|
||||
locale_readme += f"""<tr><td align="center">{view_name}<br/><br/><img src="{subdir}/{view_name}.png"></td></tr>"""
|
||||
locale_readme += """</table>\n"""
|
||||
|
||||
locale_readme += "</td></tr></table>"
|
||||
|
||||
with open(os.path.join(screenshot_renderer.screenshot_path, "README.md"), 'w') as readme_file:
|
||||
# many screens don't work, leaving a missing image, re-run here for now
|
||||
controller.psbt_seed = None
|
||||
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, "psbt_views"))
|
||||
screencap_view(psbt_views.PSBTSelectSeedView, 'PSBTSelectSeedView', {})
|
||||
|
||||
with open(os.path.join(screenshot_root, locale, "README.md"), 'w') as readme_file:
|
||||
readme_file.write(locale_readme)
|
||||
shutil.copy(translated_messages_path, os.path.join(screenshot_renderer.screenshot_path, "messages.po"))
|
||||
|
||||
if locale != "en":
|
||||
shutil.copy(translated_messages_path, os.path.join(screenshot_root, locale, "messages.po"))
|
||||
|
||||
|
||||
# Write the main README; ensure it writes all locales, not just the one that may
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
from mock import MagicMock
|
||||
from seedsigner.models import Seed
|
||||
from seedsigner.models.seed import Seed
|
||||
from embit import bip39
|
||||
|
||||
from seedsigner.models.settings import SettingsConstants
|
||||
|
||||
@@ -1,117 +1,114 @@
|
||||
import configparser
|
||||
import pytest
|
||||
from mock import MagicMock
|
||||
from seedsigner.hardware.microsd import MicroSD
|
||||
|
||||
# Must import this before the Controller
|
||||
from base import BaseTest
|
||||
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
|
||||
class TestController(BaseTest):
|
||||
|
||||
@pytest.fixture()
|
||||
def reset_controller():
|
||||
"""fixture to setup, then yield to run test, then tear down"""
|
||||
def test_reset_controller(self):
|
||||
""" The reset_controller util should completely reset the Controller singleton """
|
||||
controller = Controller.get_instance()
|
||||
controller.address_explorer_data = "foo"
|
||||
|
||||
# setup
|
||||
Controller.configure_instance(disable_hardware=True)
|
||||
|
||||
# yield to run a single test
|
||||
yield
|
||||
|
||||
# tear down
|
||||
MicroSD._instance = None
|
||||
Controller._instance = None
|
||||
|
||||
|
||||
def test_singleton_init_fails(reset_controller):
|
||||
""" The Controller should not allow any code to instantiate it via Controller() """
|
||||
with pytest.raises(Exception):
|
||||
c = Controller()
|
||||
BaseTest.reset_controller()
|
||||
controller = Controller.get_instance()
|
||||
assert controller.address_explorer_data is None
|
||||
|
||||
|
||||
def test_singleton_get_instance_preserves_state(reset_controller):
|
||||
""" Changes to the Controller singleton should be preserved across calls to get_instance() """
|
||||
|
||||
# Initialize the instance and verify that it read the config settings
|
||||
controller = Controller.get_instance()
|
||||
assert controller.unverified_address is None
|
||||
|
||||
# Change a value in the instance...
|
||||
controller.unverified_address = "123abc"
|
||||
|
||||
# ...get a new copy of the instance and confirm change
|
||||
controller = Controller.get_instance()
|
||||
assert controller.unverified_address == "123abc"
|
||||
def test_singleton_init_fails(self):
|
||||
""" The Controller should not allow any code to instantiate it via Controller() """
|
||||
with pytest.raises(Exception):
|
||||
c = Controller()
|
||||
|
||||
|
||||
def test_handle_exception(reset_controller):
|
||||
""" Handle exceptions that get caught by the controller """
|
||||
def test_handle_exception(reset_controller):
|
||||
""" Handle exceptions that get caught by the controller """
|
||||
|
||||
def process_exception_asserting_valid_error(exception_type, exception_msg=None):
|
||||
"""
|
||||
Exceptions caught by the controller are forwarded to the
|
||||
UnhandledExceptionView with view_args["error"] being a list
|
||||
of three strings, ie: [exception_type, line_info, exception_msg]
|
||||
"""
|
||||
try:
|
||||
def process_exception_asserting_valid_error(exception_type, exception_msg=None):
|
||||
"""
|
||||
Exceptions caught by the controller are forwarded to the
|
||||
UnhandledExceptionView with view_args["error"] being a list
|
||||
of three strings, ie: [exception_type, line_info, exception_msg]
|
||||
"""
|
||||
try:
|
||||
if exception_msg:
|
||||
raise exception_type(exception_msg)
|
||||
else:
|
||||
raise exception_type()
|
||||
except Exception as e:
|
||||
error = controller.handle_exception(e).view_args["error"]
|
||||
|
||||
# assert that error structure is valid
|
||||
assert len(error) == 3
|
||||
assert error[0] in str(exception_type)
|
||||
assert type(error[1]) == str
|
||||
if exception_msg:
|
||||
raise exception_type(exception_msg)
|
||||
assert exception_msg in error[2]
|
||||
else:
|
||||
raise exception_type()
|
||||
except Exception as e:
|
||||
error = controller.handle_exception(e).view_args["error"]
|
||||
assert error[2] == ""
|
||||
|
||||
# assert that error structure is valid
|
||||
assert len(error) == 3
|
||||
assert error[0] in str(exception_type)
|
||||
assert type(error[1]) == str
|
||||
if exception_msg:
|
||||
assert exception_msg in error[2]
|
||||
else:
|
||||
assert error[2] == ""
|
||||
# Initialize the controller
|
||||
controller = Controller.get_instance()
|
||||
|
||||
# Initialize the controller
|
||||
controller = Controller.get_instance()
|
||||
|
||||
exception_tests = [
|
||||
# exceptions with an exception_msg
|
||||
(Exception, "foo"),
|
||||
(KeyError, "key not found"),
|
||||
# exceptions without an exception_msg
|
||||
(Exception, ""),
|
||||
(Exception, None),
|
||||
]
|
||||
|
||||
for exception_type, exception_msg in exception_tests:
|
||||
process_exception_asserting_valid_error(exception_type, exception_msg)
|
||||
exception_tests = [
|
||||
# exceptions with an exception_msg
|
||||
(Exception, "foo"),
|
||||
(KeyError, "key not found"),
|
||||
# exceptions without an exception_msg
|
||||
(Exception, ""),
|
||||
(Exception, None),
|
||||
]
|
||||
|
||||
for exception_type, exception_msg in exception_tests:
|
||||
process_exception_asserting_valid_error(exception_type, exception_msg)
|
||||
|
||||
|
||||
def test_missing_settings_get_defaults(reset_controller):
|
||||
""" Should gracefully handle all missing fields from `settings.json` """
|
||||
def test_singleton_get_instance_preserves_state(self):
|
||||
""" Changes to the Controller singleton should be preserved across calls to get_instance() """
|
||||
|
||||
controller = Controller.get_instance()
|
||||
# Initialize the instance and verify that it read the config settings
|
||||
controller = Controller.get_instance()
|
||||
assert controller.unverified_address is None
|
||||
|
||||
# Settings defaults
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__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
|
||||
# Change a value in the instance...
|
||||
controller.unverified_address = "123abc"
|
||||
|
||||
# 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]
|
||||
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__0
|
||||
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__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
|
||||
# ...get a new copy of the instance and confirm change
|
||||
controller = Controller.get_instance()
|
||||
assert controller.unverified_address == "123abc"
|
||||
|
||||
# Hidden Settings defaults
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189
|
||||
|
||||
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]
|
||||
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__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED
|
||||
|
||||
# Hidden Settings defaults
|
||||
assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 62
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from mock import MagicMock
|
||||
from seedsigner.models import Seed, DecodeQR, DecodeQRStatus, QRType, PSBTParser
|
||||
from embit import psbt, bip39
|
||||
from seedsigner.models.decode_qr import DecodeQR, DecodeQRStatus
|
||||
from seedsigner.models.psbt_parser import PSBTParser
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from seedsigner.models.seed import Seed
|
||||
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import pytest
|
||||
|
||||
from seedsigner.models.settings_definition import SettingsConstants as SC
|
||||
from seedsigner.helpers import embit_utils
|
||||
|
||||
|
||||
def test_get_standard_derivation_path():
|
||||
"""
|
||||
tests seedsigner.helpers.embit_utils.get_standard_derivation_path()
|
||||
"""
|
||||
|
||||
vectors_args_expected = {
|
||||
# single sig
|
||||
tuple(): "m/84'/0'/0'",
|
||||
(SC.MAINNET,): "m/84'/0'/0'",
|
||||
(SC.MAINNET, SC.SINGLE_SIG, ): "m/84'/0'/0'",
|
||||
|
||||
(SC.MAINNET, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/0'/0'",
|
||||
(SC.TESTNET, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/1'/0'",
|
||||
(SC.REGTEST, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/1'/0'",
|
||||
|
||||
(SC.MAINNET, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/0'/0'",
|
||||
(SC.TESTNET, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/1'/0'",
|
||||
(SC.REGTEST, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/1'/0'",
|
||||
|
||||
(SC.MAINNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/0'/0'",
|
||||
(SC.TESTNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'",
|
||||
(SC.REGTEST, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'",
|
||||
|
||||
|
||||
# multi sig
|
||||
(SC.MAINNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/0'/0'/2'",
|
||||
(SC.TESTNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'",
|
||||
(SC.REGTEST, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'",
|
||||
|
||||
(SC.MAINNET, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/0'/0'/1'",
|
||||
(SC.TESTNET, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/1'/0'/1'",
|
||||
(SC.REGTEST, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/1'/0'/1'",
|
||||
|
||||
(SC.MAINNET, SC.MULTISIG, SC.TAPROOT): Exception,
|
||||
(SC.TESTNET, SC.MULTISIG, SC.TAPROOT): Exception,
|
||||
(SC.REGTEST, SC.MULTISIG, SC.TAPROOT): Exception,
|
||||
|
||||
# intentionally fall into exceptions
|
||||
(SC.MAINNET, SC.SINGLE_SIG, 'invalid'): Exception,
|
||||
(SC.MAINNET, SC.MULTISIG, 'invalid'): Exception,
|
||||
(SC.MAINNET, 'invalid', SC.NATIVE_SEGWIT): Exception,
|
||||
|
||||
# nonsense arguments
|
||||
("A",): Exception,
|
||||
("B", "A"): Exception,
|
||||
("C", "B", "A"): Exception,
|
||||
(True,): Exception,
|
||||
(False, True): Exception,
|
||||
(tuple(),): Exception,
|
||||
}
|
||||
func = embit_utils.get_standard_derivation_path
|
||||
|
||||
print()
|
||||
for args, expected in vectors_args_expected.items():
|
||||
|
||||
# test successful calls
|
||||
if type(expected) is str:
|
||||
# call with ordered params
|
||||
print(f"asserting {func.__name__}(*{args}) == {repr(expected)}")
|
||||
assert func(*args) == expected
|
||||
|
||||
# call with named params
|
||||
a_dict = {}
|
||||
if len(args) == 1: a_dict = {'network': args[0]}
|
||||
elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]}
|
||||
elif len(args) == 3: a_dict = {'network': args[0], 'wallet_type': args[1], 'script_type': args[2]}
|
||||
print(f"asserting {func.__name__}(**{a_dict}) == {repr(expected)}")
|
||||
assert func(**a_dict) == expected
|
||||
|
||||
# test exceptions
|
||||
else:
|
||||
# call with ordered params
|
||||
with pytest.raises(expected):
|
||||
print(f"asserting {func.__name__}(*{args}) raises Exception")
|
||||
func(*args)
|
||||
|
||||
# call with named params
|
||||
a_dict = {}
|
||||
if len(args) == 1: a_dict = {'network': args[0]}
|
||||
elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]}
|
||||
elif len(args) == 3: a_dict = {'network': args[0], 'wallet_type': args[1], 'script_type': args[2]}
|
||||
print(f"asserting {func.__name__}(**{a_dict}) raises Exception")
|
||||
with pytest.raises(expected):
|
||||
func(**a_dict)
|
||||
|
||||
|
||||
def test_get_xpub():
|
||||
"""
|
||||
tests seedsigner.helpers.embit_utils.get_xpub()
|
||||
"""
|
||||
|
||||
from binascii import unhexlify
|
||||
from embit import bip39, bip32
|
||||
|
||||
# test vectors originate from:
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki
|
||||
# https://github.com/satoshilabs/slips/blob/master/slip-0132.md
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki
|
||||
vector_seeds = (
|
||||
unhexlify("000102030405060708090a0b0c0d0e0f"),
|
||||
unhexlify("fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"),
|
||||
unhexlify("4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be"),
|
||||
unhexlify("3ddd5602285899a946114506157c7997e5444528f3003f6134712147db19b678"),
|
||||
bip39.mnemonic_to_seed("abandon "*11 + "about"),
|
||||
)
|
||||
|
||||
vectors_args_expected = {
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-1
|
||||
(vector_seeds[0], "m/", "main"): "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
|
||||
(vector_seeds[0], "m/0'", "main"): "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw",
|
||||
(vector_seeds[0], "m/0h/1", "main"): "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ",
|
||||
(vector_seeds[0], "m/0'/1/2h", "main"): "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5",
|
||||
(vector_seeds[0], "m/0'/1/2h/2", "main"): "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV",
|
||||
(vector_seeds[0], "m/0'/1/2h/2/1000000000", "main"): "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-2
|
||||
(vector_seeds[1], "m/", "main"): "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB",
|
||||
(vector_seeds[1], "m/0", "main"): "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH",
|
||||
(vector_seeds[1], "m/0/2147483647'", "main"): "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a",
|
||||
(vector_seeds[1], "m/0/2147483647h/1", "main"): "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon",
|
||||
(vector_seeds[1], "m/0/2147483647'/1/2147483646h", "main"): "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL",
|
||||
(vector_seeds[1], "m/0/2147483647h/1/2147483646'/2", "main"): "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-3
|
||||
(vector_seeds[2], "m/", "main"): "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13",
|
||||
(vector_seeds[2], "m/0h", "main"): "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-4
|
||||
(vector_seeds[3], "m/", "main"): "xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa",
|
||||
(vector_seeds[3], "m/0'", "main"): "xpub69AUMk3qDBi3uW1sXgjCmVjJ2G6WQoYSnNHyzkmdCHEhSZ4tBok37xfFEqHd2AddP56Tqp4o56AePAgCjYdvpW2PU2jbUPFKsav5ut6Ch1m",
|
||||
(vector_seeds[3], "m/0h/1'", "main"): "xpub6BJA1jSqiukeaesWfxe6sNK9CCGaujFFSJLomWHprUL9DePQ4JDkM5d88n49sMGJxrhpjazuXYWdMf17C9T5XnxkopaeS7jGk1GyyVziaMt",
|
||||
|
||||
#
|
||||
# embit_utils.get_xpub() returns the generic bip32 version "xpub", NOT the zpub/Zpub/ypub/Ypub extended versions
|
||||
#
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki#test-vectors
|
||||
# https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors
|
||||
(vector_seeds[4], "m/84'/0'/0'", "main"):
|
||||
bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs").to_base58(version=b'\x04\x88\xb2\x1e'),
|
||||
|
||||
# https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors
|
||||
(vector_seeds[4], "m/49'/0'/0'", "main"):
|
||||
bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP").to_base58(version=b'\x04\x88\xb2\x1e'),
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors
|
||||
(vector_seeds[4], "m/49'/1'/0'", "test"):
|
||||
bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY").to_base58(version=b'\x04\x35\x87\xcf'),
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors
|
||||
(vector_seeds[4], "m/86'/0'/0'", "main"): "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ",
|
||||
|
||||
}
|
||||
func = embit_utils.get_xpub
|
||||
|
||||
print()
|
||||
for args, expected in vectors_args_expected.items():
|
||||
print("\nasserting...")
|
||||
|
||||
# call without optional params (default is "main")
|
||||
if args[2] == "main":
|
||||
print(f' {func.__name__}({args[0]}, "{args[1]}") == "{expected}"')
|
||||
assert str(func(args[0], args[1])) == expected
|
||||
|
||||
# call with ordered params
|
||||
print(f' {func.__name__}(*{args}) == "{expected}"')
|
||||
assert str(func(*args)) == expected
|
||||
|
||||
# call with named params
|
||||
print(f' {func.__name__}(seed_bytes={args[0]}, derivation_path="{args[1]}", embit_network="{args[2]}") == "{expected}"')
|
||||
assert str(func(seed_bytes=args[0], derivation_path=args[1], embit_network=args[2])) == expected
|
||||
|
||||
|
||||
def test_get_single_sig_address():
|
||||
"""
|
||||
tests seedsigner.helpers.embit_utils.get_single_sig_address()
|
||||
"""
|
||||
|
||||
from embit.bip32 import HDKey
|
||||
|
||||
# test vectors originate from:
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki
|
||||
# https://github.com/satoshilabs/slips/blob/master/slip-0132.md
|
||||
# https://iancoleman.io/bip39/
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki
|
||||
|
||||
vectors_args_expected = {
|
||||
# https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of native segwit on mainnet)
|
||||
(HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, False, "main"):
|
||||
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
|
||||
# jdlcdl: derived via iancoleman test vector for first change address of native segwit on mainnet
|
||||
(HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, True, "main"):
|
||||
"bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el",
|
||||
|
||||
# https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of nested segwit on mainnet)
|
||||
(HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, False, "main"):
|
||||
"37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf",
|
||||
# jdlcdl: derived via iancoleman test vector for first change address of nested segwit on mainnet
|
||||
(HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, True, "main"):
|
||||
"34K56kSjgUCUSD8GTtuF7c9Zzwokbs6uZ7",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors (first payment address of nested segwit on testnet)
|
||||
(HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, False, "test"):
|
||||
"2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2",
|
||||
# jdlcdl: derived via iancoleman test vector for first change address of nested segwit on testnet
|
||||
(HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, True, "test"):
|
||||
"2MvdUi5o3f2tnEFh9yGvta6FzptTZtkPJC8",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first payment address of taproot on mainnet)
|
||||
(HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, False, "main"):
|
||||
"bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (second payment address of taproot on mainnet)
|
||||
(HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 1, False, "main"):
|
||||
"bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh",
|
||||
|
||||
# https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first change address of taproot on mainnet)
|
||||
(HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, True, "main"):
|
||||
"bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7",
|
||||
|
||||
# jdlcdl: derived via electrum m/44'/1'/0 (first payment address p2pkh on testnet)
|
||||
(HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, False, "test"):
|
||||
"mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV",
|
||||
|
||||
# jdlcdl: derived via electrum m/44'/1'/0 (first change address p2pkh on testnet)
|
||||
(HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, True, "test"):
|
||||
"mi8nhzZgGZQthq6DQHbru9crMDerUdTKva",
|
||||
|
||||
# jdlcdl: nonsense script_type falls off end of function returning None. TODO: Would it be preferred to "else: raise ValueError"?
|
||||
(HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "NONSENSE", 0, True, "test"):
|
||||
"None",
|
||||
}
|
||||
func = embit_utils.get_single_sig_address
|
||||
|
||||
print()
|
||||
for args, expected in vectors_args_expected.items():
|
||||
print("\nasserting...")
|
||||
|
||||
# call without optional params (defaults: script_type="nat", index=0, is_change=False, embit_network="main")
|
||||
if args[1:5] == ("nat", 0, False, "main"):
|
||||
print(f' {func.__name__}(HDKey.from_string("{args[0]}")) == "{expected}"')
|
||||
assert str(func(args[0])) == expected
|
||||
|
||||
# call with ordered params
|
||||
print(f' {func.__name__}(HDKey.from_string("{args[0]}"), *{args[1:5]}) == "{expected}"')
|
||||
assert str(func(*args)) == expected
|
||||
|
||||
# call with named params
|
||||
print(f' {func.__name__}(xpub=HDKey.from_string("{args[0]}"), script_type="{args[1]}", index={args[2]}, is_change={args[3]}, embit_network="{args[4]}") == "{expected}"')
|
||||
assert str(func(xpub=args[0], script_type=args[1], index=args[2], is_change=args[3], embit_network=args[4])) == expected
|
||||
|
||||
|
||||
def test_get_multisig_address():
|
||||
"""
|
||||
tests seedsigner.helpers.embit_utils.get_multisig_address()
|
||||
"""
|
||||
|
||||
from embit.descriptor import Descriptor
|
||||
|
||||
# jdlcdl: these vectors created with electrum & sparrow as a 2 of 3 multisig based on bip39-bip32-standard-path wallets
|
||||
# keystore1 = 0x00*16 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
# keystore2 = 0x11*16 = 'baby mass dust captain baby mass dust captain baby mass dust casino'
|
||||
# keystore3 = 0x22*16 = 'captain baby mass dust captain baby mass dust captain baby mass dutch'
|
||||
|
||||
vector_args_expected = {
|
||||
# multisig native segwit on testnet, first payment and change addresses
|
||||
("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" , 0, False, "test"): "tb1q7tpecll8jhp77yqdeyt2t8q5swxmmqeh2v22cqpms5dxlp6p27dqlftet8",
|
||||
("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" , 0, True, "test"): "tb1q7h94ywhfjrpxdfzwl4dcawrg80r4rywswjrh447x4n3e5t3m0jms9jh7pm",
|
||||
|
||||
# multisig nested segwit on testnet, first payment and change addresses
|
||||
("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", 0, False, "test"): "2MtgJH28mZWNWU7VRU4ba6ciFbRRGYWZDt3",
|
||||
("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", 0, True, "test"): "2NAjjwUQqwD9XRGLeQ6TitSUyMHUz3cLiWm",
|
||||
|
||||
# legacy multisig p2sh on testnet, not supported
|
||||
("sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c", 0, False, "test"): Exception,
|
||||
|
||||
# multisig taproot on testnet, not supported
|
||||
# TODO: find what a multisig-taproot descriptor would look like and add a test so we can fall into the last condition exception.
|
||||
|
||||
# some policy that is not supported:
|
||||
# TODO: find anything non supported so we can drop off the function: Would it be preferred to "else: raise ValueError()"?
|
||||
#("sh(multi(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#", 0, False, "test"): None,
|
||||
|
||||
}
|
||||
func = embit_utils.get_multisig_address
|
||||
|
||||
print()
|
||||
for args, expected in vector_args_expected.items():
|
||||
descriptor = Descriptor.from_string(args[0])
|
||||
|
||||
print("\nasserting...")
|
||||
|
||||
# test successful calls
|
||||
if type(expected) == str:
|
||||
# call with optional params (defaults: index=0, is_change=False, embit_network="main")
|
||||
if args[1:4] == (0, False, 'main'):
|
||||
print(f' {func.__name__}(Descriptor.from_string("{descriptor}")) == "{expected}"')
|
||||
assert func(descriptor) == expected
|
||||
|
||||
# call with ordered params
|
||||
print(f' {func.__name__}(Descriptor.from_string("{descriptor}"), *{args[1:4]}) == "{expected}"')
|
||||
assert func(descriptor, *args[1:4]) == expected
|
||||
|
||||
# call with named params
|
||||
print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"')
|
||||
assert func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) == expected
|
||||
|
||||
# test exceptions
|
||||
else:
|
||||
# call with ordered params
|
||||
with pytest.raises(expected):
|
||||
print(f' {func.__name__}(Descriptor.from_string("{descriptor}"), *{args[1:4]}) raises Exception')
|
||||
func(descriptor, *args[1:4])
|
||||
|
||||
# call with named params
|
||||
with pytest.raises(expected):
|
||||
print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") raises Exception"')
|
||||
func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3])
|
||||
|
||||
|
||||
def test_parse_derivation_path():
|
||||
# Shouldn't care if input uses "'" or "h"
|
||||
derivation_path = "m/84'/0'/0'/0/0"
|
||||
|
||||
result = embit_utils.parse_derivation_path(derivation_path)
|
||||
assert result["script_type"] == SC.NATIVE_SEGWIT
|
||||
assert result["network"] == SC.MAINNET
|
||||
|
||||
result = embit_utils.parse_derivation_path(derivation_path.replace("'", "h"))
|
||||
assert result["script_type"] == SC.NATIVE_SEGWIT
|
||||
assert result["network"] == SC.MAINNET
|
||||
|
||||
# Now exhaustively test supported permutations
|
||||
vectors_args = {
|
||||
(SC.MAINNET, SC.NATIVE_SEGWIT, False): "m/84'/0'/0'/0/5",
|
||||
(SC.TESTNET, SC.NATIVE_SEGWIT, False): "m/84'/1'/0'/0/5",
|
||||
(SC.REGTEST, SC.NATIVE_SEGWIT, False): "m/84'/1'/0'/0/5",
|
||||
(SC.MAINNET, SC.NATIVE_SEGWIT, True): "m/84'/0'/0'/1/5",
|
||||
(SC.TESTNET, SC.NATIVE_SEGWIT, True): "m/84'/1'/0'/1/5",
|
||||
(SC.REGTEST, SC.NATIVE_SEGWIT, True): "m/84'/1'/0'/1/5",
|
||||
|
||||
(SC.MAINNET, SC.NESTED_SEGWIT, False): "m/49'/0'/0'/0/5",
|
||||
(SC.TESTNET, SC.NESTED_SEGWIT, False): "m/49'/1'/0'/0/5",
|
||||
(SC.REGTEST, SC.NESTED_SEGWIT, False): "m/49'/1'/0'/0/5",
|
||||
(SC.MAINNET, SC.NESTED_SEGWIT, True): "m/49'/0'/0'/1/5",
|
||||
(SC.TESTNET, SC.NESTED_SEGWIT, True): "m/49'/1'/0'/1/5",
|
||||
(SC.REGTEST, SC.NESTED_SEGWIT, True): "m/49'/1'/0'/1/5",
|
||||
|
||||
(SC.MAINNET, SC.TAPROOT, False): "m/86'/0'/0'/0/5",
|
||||
(SC.TESTNET, SC.TAPROOT, False): "m/86'/1'/0'/0/5",
|
||||
(SC.REGTEST, SC.TAPROOT, False): "m/86'/1'/0'/0/5",
|
||||
(SC.MAINNET, SC.TAPROOT, True): "m/86'/0'/0'/1/5",
|
||||
(SC.TESTNET, SC.TAPROOT, True): "m/86'/1'/0'/1/5",
|
||||
(SC.REGTEST, SC.TAPROOT, True): "m/86'/1'/0'/1/5",
|
||||
|
||||
# Try a typical custom derivation path (Unchained vault keys)
|
||||
(SC.MAINNET, SC.CUSTOM_DERIVATION, False): "m/45'/0'/0'/0/5",
|
||||
(SC.TESTNET, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5",
|
||||
(SC.REGTEST, SC.CUSTOM_DERIVATION, False): "m/45'/1'/0'/0/5",
|
||||
(SC.MAINNET, SC.CUSTOM_DERIVATION, True): "m/45'/0'/0'/1/5",
|
||||
(SC.TESTNET, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5",
|
||||
(SC.REGTEST, SC.CUSTOM_DERIVATION, True): "m/45'/1'/0'/1/5",
|
||||
|
||||
# CRAZY custom derivation paths
|
||||
(None, SC.CUSTOM_DERIVATION, False, 5): "m/123'/9083270/9083270/9083270/9083270/0/5",
|
||||
|
||||
# non-standard change and/or index
|
||||
(None, SC.CUSTOM_DERIVATION, None, 5): "m/9'/78/5",
|
||||
(None, SC.CUSTOM_DERIVATION, None, 5): "m/9'/78'/5",
|
||||
(None, SC.CUSTOM_DERIVATION, None, None): "m/9'/78'/5'",
|
||||
(None, SC.CUSTOM_DERIVATION, False, None): "m/9'/0/5'",
|
||||
}
|
||||
|
||||
for expected_result, derivation_path in vectors_args.items():
|
||||
actual_result = embit_utils.parse_derivation_path(derivation_path)
|
||||
|
||||
if expected_result[0] == SC.MAINNET:
|
||||
assert actual_result["network"] == expected_result[0]
|
||||
assert actual_result["clean_match"] is True
|
||||
elif expected_result[0] is None:
|
||||
assert actual_result["network"] is None
|
||||
assert actual_result["clean_match"] is False
|
||||
else:
|
||||
# Testnet and regtest are returned as a list since the parser can't tell which is intended
|
||||
assert expected_result[0] in actual_result["network"]
|
||||
assert actual_result["clean_match"] is True
|
||||
|
||||
assert actual_result["script_type"] == expected_result[1]
|
||||
assert actual_result["is_change"] == expected_result[2]
|
||||
|
||||
if len(expected_result) == 4:
|
||||
assert actual_result["index"] == expected_result[3]
|
||||
else:
|
||||
assert actual_result["index"] == int(derivation_path.split("/")[-1])
|
||||
@@ -1,6 +1,5 @@
|
||||
import pytest
|
||||
from mock import MagicMock
|
||||
from seedsigner.models import EncodeQR, QRType
|
||||
from seedsigner.models.encode_qr import EncodeQR
|
||||
from seedsigner.models.qr_type import QRType
|
||||
from embit import psbt
|
||||
from binascii import a2b_base64
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import pytest
|
||||
|
||||
# Must import test base before the Controller
|
||||
from base import FlowTest, FlowStep, FlowTestUnexpectedViewException, FlowTestInvalidButtonDataSelectionException, FlowTestRunScreenNotExecutedException
|
||||
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.models.settings_definition import SettingsConstants
|
||||
from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView
|
||||
from seedsigner.views.view import MainMenuView, PowerOptionsView, UnhandledExceptionView
|
||||
from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView
|
||||
|
||||
|
||||
|
||||
class TestFlowTest(FlowTest):
|
||||
|
||||
def test_simple_flow(self):
|
||||
"""
|
||||
Basic test to ensure the FlowTest can flow through a sequence of Views and
|
||||
terminate via the StopControllerCommand.
|
||||
"""
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
|
||||
FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD),
|
||||
FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE),
|
||||
FlowStep(SeedMnemonicEntryView),
|
||||
])
|
||||
|
||||
|
||||
def test_FlowTestUnexpectedViewException(self):
|
||||
"""
|
||||
Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the next
|
||||
View in the sequence is not the expected View.
|
||||
"""
|
||||
with pytest.raises(FlowTestUnexpectedViewException):
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON),
|
||||
FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError.
|
||||
])
|
||||
|
||||
|
||||
def test_FlowTestInvalidButtonDataSelectionException(self):
|
||||
"""
|
||||
Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the
|
||||
specified button_data_selection in invalid.
|
||||
"""
|
||||
with pytest.raises(FlowTestInvalidButtonDataSelectionException):
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection="this is not a real button option!"),
|
||||
])
|
||||
|
||||
|
||||
def test_FlowTestRunScreenNotExecutedException(self):
|
||||
"""
|
||||
Ensure that the FlowTest will raise a FlowTestRunScreenNotExecutedException if the next
|
||||
View in the sequence doesn't call its View.run_screen().
|
||||
"""
|
||||
# Disable dire warnings so that the SeedWordsWarningView won't execute its run_screen()
|
||||
self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED)
|
||||
self.controller.storage.set_pending_seed(Seed(mnemonic=["bacon"] * 24))
|
||||
self.controller.storage.finalize_pending_seed()
|
||||
|
||||
with pytest.raises(FlowTestRunScreenNotExecutedException):
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP),
|
||||
FlowStep(SeedBackupView, button_data_selection=SeedBackupView.VIEW_WORDS),
|
||||
FlowStep(SeedWordsWarningView, screen_return_value=0),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_before_run_executes(self):
|
||||
"""
|
||||
Ensure that the FlowTest can execute a function before running a View.
|
||||
"""
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
def test_back_button_flow(self):
|
||||
"""
|
||||
Ensure that the FlowTest works correctly with the Controller and its BackStack.
|
||||
"""
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON),
|
||||
FlowStep(PowerOptionsView, screen_return_value=RET_CODE__BACK_BUTTON),
|
||||
FlowStep(MainMenuView),
|
||||
])
|
||||
|
||||
|
||||
def test_initial_destination(self):
|
||||
"""
|
||||
Ensure that the FlowTest can start from a View other than MainMenuView.
|
||||
"""
|
||||
# Don't have to start at the MainMenuView; can jump straight in
|
||||
self.run_sequence([
|
||||
FlowStep(ToolsCalcFinalWordNumWordsView),
|
||||
])
|
||||
|
||||
# And again, but this time with a View that requires input view_args
|
||||
self.reset_controller()
|
||||
self.controller = Controller.get_instance()
|
||||
|
||||
# Load a seed into the Controller
|
||||
seed = Seed(mnemonic=["abandon "* 11 + "about"])
|
||||
self.controller.storage.set_pending_seed(seed)
|
||||
self.controller.storage.finalize_pending_seed()
|
||||
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP),
|
||||
FlowStep(SeedBackupView),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_raise_exception_via_screen_return_value(self):
|
||||
"""
|
||||
Ensure that the FlowTest can raise an exception via the screen_return_value.
|
||||
"""
|
||||
# A generic Exception should be caught by the Controller and routed to the
|
||||
# UnhandledExceptionView.
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, screen_return_value=Exception("Test exception")),
|
||||
FlowStep(UnhandledExceptionView),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from base import FlowTest, FlowStep
|
||||
|
||||
from seedsigner.controller import Controller
|
||||
from seedsigner.views.view import MainMenuView
|
||||
from seedsigner.views import scan_views, seed_views, psbt_views
|
||||
|
||||
class TestPSBTFlows(FlowTest):
|
||||
|
||||
def test_scan_psbt_first_then_correct_seedqr_flow(self):
|
||||
"""
|
||||
Selecting "Scan" from the MainMenuView and scanning a PSBT should enter the PSBTSelectSeedView flow
|
||||
when Scan a Seed is selected from PSBTSelectSeedView it should enter the ScanView flow
|
||||
when a SeedQR is scanned it should enter the PSBTOverviewView flow
|
||||
since the PSBT has change no warning is displayed and it should enter the PSBTMathView flow
|
||||
since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow
|
||||
"""
|
||||
def load_psbt_into_decoder(view: scan_views.ScanView):
|
||||
"""
|
||||
PSBT Tx and Wallet Details
|
||||
- Single Sig Wallet P2WPKH (Native Segwit) with no passphrase
|
||||
- Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ
|
||||
- 2 Inputs
|
||||
- 56,522,834 sats
|
||||
- 1,990,245,069 sats
|
||||
- 4 Outputs
|
||||
- 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats
|
||||
- 3 Outputs change
|
||||
- 3 outputs to emulate a fake mix to increase privacy
|
||||
- Change addresses are index 1/7, 1/8, 1/9
|
||||
- 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats
|
||||
- 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats
|
||||
- 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats
|
||||
- Fee 272 sats
|
||||
"""
|
||||
view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==")
|
||||
|
||||
def load_seed_into_decoder(view: scan_views.ScanView):
|
||||
view.decoder.add_data("080115060387063104071857067618681125136207731354")
|
||||
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
|
||||
FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
|
||||
FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
|
||||
FlowStep(psbt_views.PSBTOverviewView),
|
||||
FlowStep(psbt_views.PSBTMathView),
|
||||
FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
|
||||
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
|
||||
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
|
||||
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
|
||||
FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
|
||||
FlowStep(psbt_views.PSBTSignedQRDisplayView),
|
||||
FlowStep(MainMenuView)
|
||||
])
|
||||
|
||||
def test_scan_multisig_psbt_seed_already_signed_flow(self):
|
||||
|
||||
def load_psbt_into_decoder(view: scan_views.ScanView):
|
||||
view.decoder.add_data("cHNidP8BAIkCAAAAAc9dCSh2RcRPfHaT5bNVBpbg0jAekRLqOK+bpN/QA0jeAAAAAAD9////AtAHAAAAAAAAIlEg24shYsV3IRCzlgmMKjAsR4Ad9tX896z7zDAi5q0TU9H3CgAAAAAAACIAIByGQg/VP2aRID62ty40E64HYZeRRsKRGLt8J/76R6stQ04FAE8BBDWHzwSLLGdzgAAAAq3q6nR20JnHR+vKrBQdWxN9C7xU8zNX942mVF7AQpl2ArrdLwVlkGxaatQJ4wwkvypNBKbwOq9hXGLNlKi7rZWAFDUxzXUwAACAAQAAgAAAAIACAACATwEENYfPBHOCZmWAAAACmH6KTXIny0vueRgQFBq4M6oMuG8f1QM0I/RzKQ03bCgCHrF0fyUtV0+FD2N34u/woqb8MAt/o+7Ed58RddhY8zYUCUjSaDAAAIABAACAAAAAgAIAAIAAAQEriBMAAAAAAAAiACBY4WsjDgJXLj3VW222jU1tkIIhT26ce/2efH73BWGGBiICAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5RzBEAiAPkQTY84YjFFkpD6MI2cc5rJySqws5fsTQA/8XEZFpbAIgTNVykbEH4Z7bqyzhhy6lty0K8rtCUDCaHNv+47NNIWgBAQMEAQAAAAEFR1IhApL4XO+VE1pPYn5wnRFyJQKVSc9TX2dO6KIBH6jwvgPaIQKsn5K3VDuutkAa3byXEjmTBcZ4g+zNWsJvVNCm+VwpuVKuIgYCkvhc75UTWk9ifnCdEXIlApVJz1NfZ07oogEfqPC+A9ocNTHNdTAAAIABAACAAAAAgAIAAIAAAAAAAAAAACIGAqyfkrdUO662QBrdvJcSOZMFxniD7M1awm9U0Kb5XCm5HAlI0mgwAACAAQAAgAAAAIACAACAAAAAAAAAAAAAAAEBR1IhApYXaczuYbBM/A+EH639Ir2yIB4PxL46dK/I1V1O9aHgIQLa02HCI/+EP+9gGpxHskjYWFN5hZzXY7RRvwV4UF42ylKuIgIClhdpzO5hsEz8D4Qfrf0ivbIgHg/Evjp0r8jVXU71oeAcNTHNdTAAAIABAACAAAAAgAIAAIABAAAAAAAAACICAtrTYcIj/4Q/72AanEeySNhYU3mFnNdjtFG/BXhQXjbKHAlI0mgwAACAAQAAgAAAAIACAACAAQAAAAAAAAAA")
|
||||
|
||||
def load_seed_into_decoder(view: scan_views.ScanView):
|
||||
view.decoder.add_data("073318950739065415961602009907670428187212261116")
|
||||
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored
|
||||
FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
|
||||
FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
|
||||
FlowStep(psbt_views.PSBTOverviewView),
|
||||
FlowStep(psbt_views.PSBTMathView),
|
||||
FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
|
||||
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
|
||||
FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
|
||||
FlowStep(psbt_views.PSBTSigningErrorView, button_data_selection=psbt_views.PSBTSigningErrorView.SELECT_DIFF_SEED),
|
||||
FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED),
|
||||
FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder),
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
|
||||
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="abc"),
|
||||
FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
|
||||
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
|
||||
FlowStep(psbt_views.PSBTOverviewView),
|
||||
FlowStep(psbt_views.PSBTMathView),
|
||||
FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=0),
|
||||
FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT),
|
||||
FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT),
|
||||
FlowStep(psbt_views.PSBTSignedQRDisplayView),
|
||||
FlowStep(MainMenuView),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
from typing import Callable
|
||||
import pytest
|
||||
|
||||
# Must import test base before the Controller
|
||||
from base import BaseTest, FlowTest, FlowStep
|
||||
from base import FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException
|
||||
|
||||
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON
|
||||
from seedsigner.models.settings import Settings, SettingsConstants
|
||||
from seedsigner.models.seed import Seed
|
||||
from seedsigner.views.view import ErrorView, MainMenuView, OptionDisabledView, RemoveMicroSDWarningView, View, NetworkMismatchErrorView
|
||||
from seedsigner.views import seed_views, scan_views, settings_views, tools_views
|
||||
|
||||
|
||||
def load_seed_into_decoder(view: scan_views.ScanView):
|
||||
view.decoder.add_data("0000" * 11 + "0003")
|
||||
|
||||
|
||||
|
||||
class TestSeedFlows(FlowTest):
|
||||
|
||||
def test_scan_seedqr_flow(self):
|
||||
"""
|
||||
Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the
|
||||
Finalize Seed flow and end at the SeedOptionsView.
|
||||
"""
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView),
|
||||
])
|
||||
|
||||
|
||||
def test_mnemonic_entry_flow(self):
|
||||
"""
|
||||
Manually entering a mnemonic should land at the Finalize Seed flow and end at
|
||||
the SeedOptionsView.
|
||||
"""
|
||||
def test_with_mnemonic(mnemonic):
|
||||
Settings.HOSTNAME = "not seedsigner-os"
|
||||
sequence = [
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
|
||||
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView
|
||||
FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD),
|
||||
]
|
||||
|
||||
# Now add each manual word entry step
|
||||
for word in mnemonic:
|
||||
sequence.append(
|
||||
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)
|
||||
)
|
||||
|
||||
# With the mnemonic completely entered, we land on the SeedFinalizeView
|
||||
sequence += [
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView),
|
||||
]
|
||||
|
||||
self.run_sequence(sequence)
|
||||
|
||||
# Test data from iancoleman.io; 12- and 24-word mnemonic
|
||||
test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split())
|
||||
|
||||
BaseTest.reset_controller()
|
||||
|
||||
test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split())
|
||||
|
||||
|
||||
def test_invalid_mnemonic(self):
|
||||
""" Should be able to go back and edit or discard an invalid mnemonic """
|
||||
# Test data from iancoleman.io
|
||||
mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split()
|
||||
sequence = [
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
|
||||
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView
|
||||
FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD),
|
||||
]
|
||||
for word in mnemonic[:-1]:
|
||||
sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word))
|
||||
|
||||
sequence += [
|
||||
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zoo"), # But finish with an INVALID checksum word
|
||||
FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT),
|
||||
]
|
||||
|
||||
# Restarts from first word
|
||||
for word in mnemonic[:-1]:
|
||||
sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word))
|
||||
|
||||
sequence += [
|
||||
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word
|
||||
FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD),
|
||||
FlowStep(MainMenuView),
|
||||
]
|
||||
|
||||
self.run_sequence(sequence)
|
||||
|
||||
|
||||
def test_export_xpub_standard_flow(self):
|
||||
"""
|
||||
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):
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
|
||||
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_tuple[1]),
|
||||
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_tuple[1]),
|
||||
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coord_tuple[1]),
|
||||
FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
|
||||
FlowStep(MainMenuView),
|
||||
]
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
# 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
|
||||
|
||||
# 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])
|
||||
|
||||
# exhaustively test flows thru standard sig_types, script_types, and coordinators
|
||||
for sig_tuple in sig_types:
|
||||
for script_tuple in script_types:
|
||||
for coord_tuple in coordinators:
|
||||
# skip custom derivation
|
||||
if script_tuple[0] == SettingsConstants.CUSTOM_DERIVATION:
|
||||
continue
|
||||
# skip multisig taproot
|
||||
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)
|
||||
|
||||
|
||||
def test_export_xpub_disabled_not_available_flow(self):
|
||||
"""
|
||||
If sig_type/script_type/coordinator 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()
|
||||
self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic))
|
||||
self.controller.storage.finalize_pending_seed()
|
||||
|
||||
# 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
|
||||
|
||||
# these are the disabled types that we will be testing
|
||||
disabled_sig = SettingsConstants.MULTISIG
|
||||
disabled_script = SettingsConstants.TAPROOT
|
||||
disabled_coord = SettingsConstants.COORDINATOR__NUNCHUK
|
||||
|
||||
# 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])
|
||||
|
||||
# test that multisig is not an option via exception raised when redirected to next step instead of having a choice
|
||||
with pytest.raises(FlowTestRunScreenNotExecutedException) as e:
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
|
||||
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=disabled_sig),
|
||||
]
|
||||
)
|
||||
|
||||
# test that taproot is not an option via exception raised when choice is taproot
|
||||
with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e:
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
|
||||
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=disabled_script),
|
||||
]
|
||||
)
|
||||
|
||||
# test that nunchuk is not an option via exception raised when choice is nunchuk
|
||||
with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e:
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
|
||||
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_export_xpub_custom_derivation_flow(self):
|
||||
"""
|
||||
Export XPUB flow for custom derivation finishes at MainMenuView
|
||||
"""
|
||||
# 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()
|
||||
|
||||
# enable custom derivation script_type setting (plus at least one more for a choice)
|
||||
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [
|
||||
SettingsConstants.NATIVE_SEGWIT,
|
||||
SettingsConstants.NESTED_SEGWIT,
|
||||
SettingsConstants.CUSTOM_DERIVATION
|
||||
])
|
||||
|
||||
# get display names to access button choices in the views (ugh: hardcoding, is there a better way?)
|
||||
sig_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # single sig
|
||||
script_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2] # custom derivation
|
||||
coordinator = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # specter
|
||||
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
|
||||
FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_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.SeedExportXpubWarningView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0),
|
||||
FlowStep(MainMenuView),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
# 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
|
||||
self.settings.update({
|
||||
SettingsConstants.SETTING__SIG_TYPES: SettingsConstants.MULTISIG,
|
||||
SettingsConstants.SETTING__SCRIPT_TYPES: SettingsConstants.NESTED_SEGWIT,
|
||||
SettingsConstants.SETTING__COORDINATORS: SettingsConstants.COORDINATOR__SPECTER_DESKTOP,
|
||||
})
|
||||
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB),
|
||||
FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedExportXpubCoordinatorView, 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),
|
||||
FlowStep(MainMenuView),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_discard_seed_flow(self):
|
||||
"""
|
||||
Selecting "Discard Seed" from the SeedOptionsView should enter the Discard Seed flow and
|
||||
remove the in-memory seed from the Controller.
|
||||
"""
|
||||
# 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()
|
||||
|
||||
self.run_sequence(
|
||||
initial_destination_view_args=dict(seed_num=0),
|
||||
sequence=[
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.DISCARD),
|
||||
FlowStep(seed_views.SeedDiscardView, button_data_selection=seed_views.SeedDiscardView.DISCARD),
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
|
||||
FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView
|
||||
FlowStep(seed_views.LoadSeedView),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
|
||||
class TestMessageSigningFlows(FlowTest):
|
||||
MAINNET_DERIVATION_PATH = "m/84h/0h/0h/0/0"
|
||||
TESTNET_DERIVATION_PATH = "m/84h/1h/0h/0/0"
|
||||
SHORT_MESSAGE = "I attest that I control this bitcoin address blah blah blah"
|
||||
MULTIPAGE_MESSAGE = """Chancellor on brink of second bailout for banks
|
||||
|
||||
Billions may be needed as lending squeeze tightens
|
||||
|
||||
Alistair Darling has been forced to consider a second bailout for banks as the lending drought worsens.
|
||||
|
||||
The Chancellor will decide within weeks whether to pump billions more into the economy as evidence mounts that the £37 billion part-nationalisation last year has failed to keep credit flowing. Options include cash injections, offering banks cheaper state guarantees to raise money privately or buying up “toxic assets”, The Times has learnt."""
|
||||
|
||||
|
||||
def load_seed_into_decoder(self, view: scan_views.ScanView):
|
||||
view.decoder.add_data("0000" * 11 + "0003")
|
||||
|
||||
|
||||
def load_signmessage_into_decoder(self, view:View, derivation_path: str, message: str):
|
||||
view.decoder.add_data(f"signmessage {derivation_path} ascii:{message}")
|
||||
|
||||
|
||||
def load_short_message_into_decoder(self, view: View):
|
||||
self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.SHORT_MESSAGE)
|
||||
|
||||
|
||||
def load_testnet_message_into_decoder(self, view: View):
|
||||
self.load_signmessage_into_decoder(view, self.TESTNET_DERIVATION_PATH, self.SHORT_MESSAGE)
|
||||
|
||||
|
||||
def load_multipage_message_into_decoder(self, view: View):
|
||||
self.load_signmessage_into_decoder(view, self.MAINNET_DERIVATION_PATH, self.MULTIPAGE_MESSAGE)
|
||||
|
||||
|
||||
def inject_mesage_as_paged_message(self, view: View):
|
||||
# Because the Screen won't actually run, we have to do the Screen's work here
|
||||
from seedsigner.gui.components import reflow_text_into_pages, GUIConstants
|
||||
paged = reflow_text_into_pages(
|
||||
text=self.controller.sign_message_data["message"],
|
||||
width=240 - 2*GUIConstants.EDGE_PADDING,
|
||||
height=240 - GUIConstants.TOP_NAV_HEIGHT - 3*GUIConstants.EDGE_PADDING - GUIConstants.BUTTON_HEIGHT,
|
||||
)
|
||||
self.controller.sign_message_data["paged_message"] = paged
|
||||
|
||||
|
||||
def test_sign_message_flow(self):
|
||||
"""
|
||||
Should scan a `signmessage` QR and complete the message review, address review,
|
||||
and signing flow.
|
||||
"""
|
||||
# Ensure message signing is enabled
|
||||
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
|
||||
|
||||
# Scenario 1: Load the mesage first, then the seed
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored
|
||||
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0),
|
||||
FlowStep(MainMenuView),
|
||||
])
|
||||
|
||||
# Scenario 2: Scan the seed first, then select Sign Message
|
||||
self.controller.discard_seed(0)
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored
|
||||
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0),
|
||||
FlowStep(MainMenuView),
|
||||
])
|
||||
|
||||
# Scenario 3: Load a long, multipage message
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_multipage_message_into_decoder), # simulate read message QR; ret val is ignored
|
||||
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedSelectSeedView, button_data_selection=seed_views.SeedSelectSeedView.SCAN_SEED),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, before_run=self.inject_mesage_as_paged_message, screen_return_value=0), # page 1/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 2/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 3/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 4/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 5/5
|
||||
|
||||
# Arrive at the address confirmation, then go backwards to re-review the paged message
|
||||
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=RET_CODE__BACK_BUTTON), # then back to page 5/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 4/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 3/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 2/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=RET_CODE__BACK_BUTTON), # back to page 1/5
|
||||
|
||||
# Now proceed forward again to the end
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 1/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 2/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 3/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 4/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmMessageView, screen_return_value=0), # page 5/5
|
||||
FlowStep(seed_views.SeedSignMessageConfirmAddressView, screen_return_value=0),
|
||||
FlowStep(seed_views.SeedSignMessageSignedMessageQRView, screen_return_value=0),
|
||||
FlowStep(MainMenuView),
|
||||
])
|
||||
|
||||
|
||||
def test_sign_message_network_mismatch_flow(self):
|
||||
"""
|
||||
Should redirect to NetworkMismatchErrorView if a message's derivation path network doesn't match the current network.
|
||||
|
||||
The error view should then forward to the Network Settings update View.
|
||||
"""
|
||||
# Ensure message signing is enabled
|
||||
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
|
||||
|
||||
def expect_network_mismatch_error(load_message: Callable):
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=load_message), # simulate read message QR; ret val is ignored
|
||||
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
|
||||
FlowStep(NetworkMismatchErrorView),
|
||||
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
|
||||
])
|
||||
|
||||
# MAINNET settings vs TESTNET derivation path with the message
|
||||
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.MAINNET)
|
||||
expect_network_mismatch_error(self.load_testnet_message_into_decoder)
|
||||
|
||||
# TESTNET settings vs MAINNET derivation path with the message
|
||||
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.TESTNET)
|
||||
expect_network_mismatch_error(self.load_short_message_into_decoder)
|
||||
|
||||
# REGTEST settings vs MAINNET derivation path with the message
|
||||
self.settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST)
|
||||
expect_network_mismatch_error(self.load_short_message_into_decoder)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_sign_message_option_disabled(self):
|
||||
"""
|
||||
Should redirect to OptionDisabledView if a `signmessage` QR is scanned with
|
||||
message signing disabled.
|
||||
|
||||
Should offer the option to route directly to enable that settings or return to
|
||||
MainMenuView.
|
||||
"""
|
||||
# Ensure message signing is disabled
|
||||
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__DISABLED)
|
||||
|
||||
sequence = [
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_short_message_into_decoder), # simulate read message QR; ret val is ignored
|
||||
FlowStep(seed_views.SeedSignMessageStartView, is_redirect=True),
|
||||
]
|
||||
|
||||
# First test routing to update the setting
|
||||
self.run_sequence(
|
||||
sequence + [
|
||||
FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.UPDATE_SETTING, is_redirect=True),
|
||||
FlowStep(settings_views.SettingsEntryUpdateSelectionView),
|
||||
]
|
||||
)
|
||||
|
||||
# Now test exiting to Main Menu
|
||||
self.run_sequence(
|
||||
sequence + [
|
||||
FlowStep(OptionDisabledView, button_data_selection=OptionDisabledView.DONE, is_redirect=True),
|
||||
FlowStep(MainMenuView),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_sign_message_invalid_qr_flow(self):
|
||||
"""
|
||||
Should clear `Controller.resume_main_flow` and redirect to ErrorView if an
|
||||
invalid signmessage QR is scanned.
|
||||
|
||||
The error view should then forward to MainMenuView.
|
||||
"""
|
||||
# Ensure message signing is enabled
|
||||
self.settings.set_value(SettingsConstants.SETTING__MESSAGE_SIGNING, SettingsConstants.OPTION__ENABLED)
|
||||
|
||||
def load_invalid_signmessage_qr(view: scan_views.ScanView):
|
||||
view.decoder.add_data("this text will not make sense to the decoder")
|
||||
|
||||
self.run_sequence([
|
||||
FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN),
|
||||
FlowStep(scan_views.ScanView, before_run=self.load_seed_into_decoder), # simulate read SeedQR; ret val is ignored
|
||||
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
|
||||
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.SIGN_MESSAGE),
|
||||
FlowStep(scan_views.ScanView, before_run=load_invalid_signmessage_qr), # simulate read message QR; ret val is ignored
|
||||
FlowStep(ErrorView),
|
||||
FlowStep(MainMenuView),
|
||||
])
|
||||
|
||||
assert self.controller.resume_main_flow is None
|
||||