mirror of
https://github.com/LBRYFoundation/LBRY-Vault.git
synced 2025-08-23 17:47:31 +00:00
Rebase 3.3.8
This commit is contained in:
parent
d97c635d28
commit
48a9c7f9dc
8 changed files with 374 additions and 157 deletions
11
MANIFEST.in
11
MANIFEST.in
|
@ -1,6 +1,5 @@
|
||||||
include LICENCE RELEASE-NOTES AUTHORS
|
include LICENCE RELEASE-NOTES AUTHORS
|
||||||
include README.rst
|
include README.rst
|
||||||
include electrum.conf.sample
|
|
||||||
include electrum.desktop
|
include electrum.desktop
|
||||||
include *.py
|
include *.py
|
||||||
include run_electrum
|
include run_electrum
|
||||||
|
@ -8,11 +7,15 @@ include contrib/requirements/requirements.txt
|
||||||
include contrib/requirements/requirements-hw.txt
|
include contrib/requirements/requirements-hw.txt
|
||||||
recursive-include packages *.py
|
recursive-include packages *.py
|
||||||
recursive-include packages cacert.pem
|
recursive-include packages cacert.pem
|
||||||
include icons.qrc
|
|
||||||
graft icons
|
|
||||||
|
|
||||||
graft electrum
|
graft electrum
|
||||||
prune electrum/tests
|
prune electrum/tests
|
||||||
|
graft contrib/udev
|
||||||
|
|
||||||
|
exclude electrum/*.so
|
||||||
|
exclude electrum/*.so.0
|
||||||
|
|
||||||
global-exclude __pycache__
|
global-exclude __pycache__
|
||||||
global-exclude *.py[co]
|
global-exclude *.py[co~]
|
||||||
|
global-exclude *.py.orig
|
||||||
|
global-exclude *.py.rej
|
||||||
|
|
69
README.rst
69
README.rst
|
@ -26,13 +26,30 @@ Electrum - Lightweight Bitcoin client
|
||||||
Getting started
|
Getting started
|
||||||
===============
|
===============
|
||||||
|
|
||||||
Electrum is a pure python application. If you want to use the
|
Electrum itself is pure Python, and so are most of the required dependencies.
|
||||||
Qt interface, install the Qt dependencies::
|
|
||||||
|
Non-python dependencies
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
If you want to use the Qt interface, install the Qt dependencies::
|
||||||
|
|
||||||
sudo apt-get install python3-pyqt5
|
sudo apt-get install python3-pyqt5
|
||||||
|
|
||||||
|
For elliptic curve operations, libsecp256k1 is a required dependency::
|
||||||
|
|
||||||
|
sudo apt-get install libsecp256k1-0
|
||||||
|
|
||||||
|
Alternatively, when running from a cloned repository, a script is provided to build
|
||||||
|
libsecp256k1 yourself::
|
||||||
|
|
||||||
|
./contrib/make_libsecp256k1.sh
|
||||||
|
|
||||||
|
|
||||||
|
Running from tar.gz
|
||||||
|
-------------------
|
||||||
|
|
||||||
If you downloaded the official package (tar.gz), you can run
|
If you downloaded the official package (tar.gz), you can run
|
||||||
Electrum from its root directory, without installing it on your
|
Electrum from its root directory without installing it on your
|
||||||
system; all the python dependencies are included in the 'packages'
|
system; all the python dependencies are included in the 'packages'
|
||||||
directory. To run Electrum from its root directory, just do::
|
directory. To run Electrum from its root directory, just do::
|
||||||
|
|
||||||
|
@ -40,40 +57,30 @@ directory. To run Electrum from its root directory, just do::
|
||||||
|
|
||||||
You can also install Electrum on your system, by running this command::
|
You can also install Electrum on your system, by running this command::
|
||||||
|
|
||||||
sudo apt-get install python3-setuptools
|
sudo apt-get install python3-setuptools python3-pip
|
||||||
python3 -m pip install .[fast]
|
python3 -m pip install --user .
|
||||||
|
|
||||||
This will download and install the Python dependencies used by
|
This will download and install the Python dependencies used by
|
||||||
Electrum, instead of using the 'packages' directory.
|
Electrum instead of using the 'packages' directory.
|
||||||
The 'fast' extra contains some optional dependencies that we think
|
|
||||||
are often useful but they are not strictly needed.
|
|
||||||
|
|
||||||
If you cloned the git repository, you need to compile extra files
|
If you cloned the git repository, you need to compile extra files
|
||||||
before you can run Electrum. Read the next section, "Development
|
before you can run Electrum. Read the next section, "Development
|
||||||
Version".
|
version".
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Development version
|
Development version
|
||||||
===================
|
-------------------
|
||||||
|
|
||||||
Check out the code from GitHub::
|
Check out the code from GitHub::
|
||||||
|
|
||||||
git clone git://github.com/spesmilo/electrum.git
|
git clone git://github.com/spesmilo/electrum.git
|
||||||
cd electrum
|
cd electrum
|
||||||
|
git submodule update --init
|
||||||
|
|
||||||
Run install (this should install dependencies)::
|
Run install (this should install dependencies)::
|
||||||
|
|
||||||
python3 -m pip install .[fast]
|
python3 -m pip install --user .
|
||||||
|
|
||||||
Render the SVG icons to PNGs (optional)::
|
|
||||||
|
|
||||||
for i in lock unlock confirmed status_lagging status_disconnected status_connected_proxy status_connected status_waiting preferences; do convert -background none icons/$i.svg icons/$i.png; done
|
|
||||||
|
|
||||||
Compile the icons file for Qt::
|
|
||||||
|
|
||||||
sudo apt-get install pyqt5-dev-tools
|
|
||||||
pyrcc5 icons.qrc -o electrum/gui/qt/icons_rc.py
|
|
||||||
|
|
||||||
Compile the protobuf description file::
|
Compile the protobuf description file::
|
||||||
|
|
||||||
|
@ -83,7 +90,7 @@ Compile the protobuf description file::
|
||||||
Create translations (optional)::
|
Create translations (optional)::
|
||||||
|
|
||||||
sudo apt-get install python-requests gettext
|
sudo apt-get install python-requests gettext
|
||||||
./contrib/make_locale
|
./contrib/pull_locale
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -91,25 +98,31 @@ Create translations (optional)::
|
||||||
Creating Binaries
|
Creating Binaries
|
||||||
=================
|
=================
|
||||||
|
|
||||||
|
Linux (tarball)
|
||||||
|
---------------
|
||||||
|
|
||||||
To create binaries, create the 'packages' directory::
|
See :code:`contrib/build-linux/README.md`.
|
||||||
|
|
||||||
./contrib/make_packages
|
|
||||||
|
|
||||||
This directory contains the python dependencies used by Electrum.
|
Linux (AppImage)
|
||||||
|
----------------
|
||||||
|
|
||||||
|
See :code:`contrib/build-linux/appimage/README.md`.
|
||||||
|
|
||||||
|
|
||||||
Mac OS X / macOS
|
Mac OS X / macOS
|
||||||
--------
|
----------------
|
||||||
|
|
||||||
|
See :code:`contrib/osx/README.md`.
|
||||||
|
|
||||||
See `contrib/build-osx/`.
|
|
||||||
|
|
||||||
Windows
|
Windows
|
||||||
-------
|
-------
|
||||||
|
|
||||||
See `contrib/build-wine/`.
|
See :code:`contrib/build-wine/README.md`.
|
||||||
|
|
||||||
|
|
||||||
Android
|
Android
|
||||||
-------
|
-------
|
||||||
|
|
||||||
See `electrum/gui/kivy/Readme.md` file.
|
See :code:`electrum/gui/kivy/Readme.md`.
|
||||||
|
|
171
RELEASE-NOTES
171
RELEASE-NOTES
|
@ -1,3 +1,174 @@
|
||||||
|
# Release 4.0 - (Not released yet; release notes are incomplete)
|
||||||
|
|
||||||
|
* Lightning Network
|
||||||
|
* Qt GUI: Separation between output selection and transaction finalization.
|
||||||
|
* Http PayServer can be configured from GUI
|
||||||
|
|
||||||
|
# Release 3.3.8 - (July 11, 2019)
|
||||||
|
|
||||||
|
* fix some bugs with recent bump fee (RBF) improvements (#5483, #5502)
|
||||||
|
* fix #5491: watch-only wallets could not bump fee in some cases
|
||||||
|
* appimage: URLs could not be opened on some desktop environments (#5425)
|
||||||
|
* faster tx signing for segwit inputs for really large txns (#5494)
|
||||||
|
* A few other minor bugfixes and usability improvements.
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.7 - (July 3, 2019)
|
||||||
|
|
||||||
|
* The AppImage Linux x86_64 binary and the Windows setup.exe
|
||||||
|
(so now all Windows binaries) are now built reproducibly.
|
||||||
|
* Bump fee (RBF) improvements:
|
||||||
|
Implemented a new fee-bump strategy that can add new inputs,
|
||||||
|
so now any tx can be fee-bumped (d0a4366). The old strategy
|
||||||
|
was to decrease the value of outputs (starting with change).
|
||||||
|
We will now try the new strategy first, and only use the old
|
||||||
|
as a fallback (needed e.g. when spending "Max").
|
||||||
|
* CoinChooser improvements:
|
||||||
|
- more likely to construct txs without change (when possible)
|
||||||
|
- less likely to construct txs with really small change (e864fa5)
|
||||||
|
- will now only spend negative effective value coins when
|
||||||
|
beneficial for privacy (cb69aa8)
|
||||||
|
* fix long-standing bug that broke wallets with >65k addresses (#5366)
|
||||||
|
* Windows binaries: we now build the PyInstaller boot loader ourselves,
|
||||||
|
as this seems to reduce anti-virus false positives (1d0f679)
|
||||||
|
* Android: (fix) BIP70 payment requests could not be paid (#5376)
|
||||||
|
* Android: allow copy-pasting partial transactions from/to clipboard
|
||||||
|
* Fix a performance regression for large wallets (c6a54f0)
|
||||||
|
* Qt: fix some high DPI issues related to text fields (37809be)
|
||||||
|
* Trezor:
|
||||||
|
- allow bypassing "too old firmware" error (#5391)
|
||||||
|
- use only the Bridge to scan devices if it is available (#5420)
|
||||||
|
* hw wallets: (known issue) on Win10-1903, some hw devices
|
||||||
|
(that also have U2F functionality) can only be detected with
|
||||||
|
Administrator privileges. (see #5420 and #5437)
|
||||||
|
A workaround is to run as Admin, or for Trezor to install the Bridge.
|
||||||
|
* Several other minor bugfixes and usability improvements.
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.6 - (May 16, 2019)
|
||||||
|
|
||||||
|
* qt: fix crash during 2FA wallet creation (#5334)
|
||||||
|
* fix synchronizer not to keep resubscribing to addresses of
|
||||||
|
already closed wallets (e415c0d9)
|
||||||
|
* fix removing addresses/keys from imported wallets (#4481)
|
||||||
|
* kivy: fix crash when aborting 2FA wallet creation (#5333)
|
||||||
|
* kivy: fix rare crash when changing exchange rate settings (#5329)
|
||||||
|
* A few other minor bugfixes and usability improvements.
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.5 - (May 9, 2019)
|
||||||
|
|
||||||
|
* The logging system has been overhauled (#5296).
|
||||||
|
Logs can now also optionally be written to disk, disabled by default.
|
||||||
|
* Fix a bug in synchronizer (#5122) where client could get stuck.
|
||||||
|
Also, show the progress of history sync in the GUI. (#5319)
|
||||||
|
* fix Revealer in Windows and MacOS binaries (#5027)
|
||||||
|
* fiat rate providers:
|
||||||
|
- added CoinGecko.com and CoinCap.io
|
||||||
|
- BitcoinAverage now only provides historical exchange rates for
|
||||||
|
paying customers. Changed default provider to CoinGecko.com (#5188)
|
||||||
|
* hardware wallets:
|
||||||
|
- Ledger: Nano X is now recognized (#5140)
|
||||||
|
- KeepKey:
|
||||||
|
- device was not getting detected using Windows binary (#5165)
|
||||||
|
- support firmware 6.0.0+ (#5205)
|
||||||
|
- Trezor: implemented "seedless" mode (#5118)
|
||||||
|
* Coin Control in Qt: implemented freezing individual UTXOs
|
||||||
|
in addition to freezing addresses (#5152)
|
||||||
|
* TrustedCoin (2FA wallets):
|
||||||
|
- better error messages (#5184)
|
||||||
|
- longer signing timeout (#5221)
|
||||||
|
* Kivy:
|
||||||
|
- fix bug with local transactions (#5156)
|
||||||
|
- allow selecting fiat rate providers without historical data (#5162)
|
||||||
|
* fix CPFP: the fees already paid by the parent were not included in
|
||||||
|
the calculation, so it always overestimated (#5244)
|
||||||
|
* Testnet: there is now a warning when the client is started in
|
||||||
|
testnet mode as there were a number of reports of users getting
|
||||||
|
scammed through social engineering (#5295)
|
||||||
|
* CoinChooser: performance of creating transactions has been improved
|
||||||
|
significantly for large wallets. (d56917f4)
|
||||||
|
* Importing/sweeping WIF keys: stricter checks (#4638, #5290)
|
||||||
|
* Electrum protocol: the client's "user agent" has been changed from
|
||||||
|
"3.3.5" to "electrum/3.3.5". Other libraries connecting to servers
|
||||||
|
can consider not "spoofing" to be Electrum. (#5246)
|
||||||
|
* Several other minor bugfixes and usability improvements.
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.4 - (February 13, 2019)
|
||||||
|
|
||||||
|
* AppImage: we now also distribute self-contained binaries for x86_64
|
||||||
|
Linux in the form of an AppImage (#5042). The Python interpreter,
|
||||||
|
PyQt5, libsecp256k1, PyCryptodomex, zbar, hidapi/libusb (including
|
||||||
|
hardware wallet libraries) are all bundled. Note that users of
|
||||||
|
hw wallets still need to set udev rules themselves.
|
||||||
|
* hw wallets: fix a regression during transaction signing that prompts
|
||||||
|
the user too many times for confirmations (commit 2729909)
|
||||||
|
* transactions now set nVersion to 2, to mimic Bitcoin Core
|
||||||
|
* fix Qt bug that made all hw wallets unusable on Windows 8.1 (#4960)
|
||||||
|
* fix bugs in wallet creation wizard that resulted in corrupted
|
||||||
|
wallets being created in rare cases (#5082, #5057)
|
||||||
|
* fix compatibility with Qt 5.12 (#5109)
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.3 - (January 25, 2019)
|
||||||
|
|
||||||
|
* Do not expose users to server error messages (#4968)
|
||||||
|
* Notify users of new releases. Release announcements must be signed,
|
||||||
|
and they are verified byElectrum using a hardcoded Bitcoin address.
|
||||||
|
* Hardware wallet fixes (#4991, #4993, #5006)
|
||||||
|
* Display only QR code in QRcode Window
|
||||||
|
* Fixed code signing on MacOS
|
||||||
|
* Randomise locktime of transactions
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.2 - (December 21, 2018)
|
||||||
|
|
||||||
|
* Fix Qt history export bug
|
||||||
|
* Improve network timeouts
|
||||||
|
* Prepend server transaction_broadcast error messages with
|
||||||
|
explanatory message. Render error messages as plain text.
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.1 - (December 20, 2018)
|
||||||
|
|
||||||
|
* Qt: Fix invoices tab crash (#4941)
|
||||||
|
* Android: Minor GUI improvements
|
||||||
|
|
||||||
|
|
||||||
|
# Release 3.3.0 - Hodler's Edition (December 19, 2018)
|
||||||
|
|
||||||
|
* The network layer has been rewritten using asyncio and aiorpcx.
|
||||||
|
In addition to easier maintenance, this makes the client
|
||||||
|
more robust against misbehaving servers.
|
||||||
|
* The minimum python version was increased to 3.6
|
||||||
|
* The blockchain headers and fork handling logic has been generalized.
|
||||||
|
Clients by default now follow chain based on most work, not length.
|
||||||
|
* New wallet creation defaults to native segwit (bech32).
|
||||||
|
* Segwit 2FA: TrustedCoin now supports native segwit p2wsh
|
||||||
|
two-factor wallets.
|
||||||
|
* RBF batching (opt-in): If the wallet has an unconfirmed RBF
|
||||||
|
transaction, new payments will be added to that transaction,
|
||||||
|
instead of creating new transactions.
|
||||||
|
* MacOS: support QR code scanner in binaries.
|
||||||
|
* Android APK:
|
||||||
|
- build using Google NDK instead of Crystax NDK
|
||||||
|
- target API 28
|
||||||
|
- do not use external storage (previously for block headers)
|
||||||
|
* hardware wallets:
|
||||||
|
- Coldcard now supports spending from p2wpkh-p2sh,
|
||||||
|
fixed p2pkh signing for fw 1.1.0
|
||||||
|
- Archos Safe-T mini: fix #4726 signing issue
|
||||||
|
- KeepKey: full segwit support
|
||||||
|
- Trezor: refactoring and compat with python-trezor 0.11
|
||||||
|
- Digital BitBox: support firmware v5.0.0
|
||||||
|
* fix bitcoin URI handling when app already running (#4796)
|
||||||
|
* Qt listings rewritten:
|
||||||
|
the History tab now uses QAbstractItemModel, the other tabs use
|
||||||
|
QStandardItemModel. Performance should be better for large wallets.
|
||||||
|
* Several other minor bugfixes and usability improvements.
|
||||||
|
|
||||||
|
|
||||||
# Release 3.2.3 - (September 3, 2018)
|
# Release 3.2.3 - (September 3, 2018)
|
||||||
|
|
||||||
* hardware wallet: the Safe-T mini from Archos is now supported.
|
* hardware wallet: the Safe-T mini from Archos is now supported.
|
||||||
|
|
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
To report security issues send an email to electrumdev@gmail.com.
|
||||||
|
|
||||||
|
The following keys may be used to communicate sensitive information to developers:
|
||||||
|
|
||||||
|
| Name | Fingerprint |
|
||||||
|
|------|-------------|
|
||||||
|
| ThomasV | 6694 D8DE 7BE8 EE56 31BE D950 2BD5 824B 7F94 70E6 |
|
||||||
|
| SomberNight | 4AD6 4339 DFA0 5E20 B3F6 AD51 E7B7 48CD AF5E 5ED9 |
|
||||||
|
|
||||||
|
You can import a key by running the following command with that
|
||||||
|
individual’s fingerprint: `gpg --recv-keys "<fingerprint>"`
|
||||||
|
Ensure that you put quotes around fingerprints containing spaces.
|
||||||
|
|
||||||
|
These public keys can also be found in the Electrum git repository,
|
||||||
|
in the top-level `pubkeys` folder.
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# This script creates a virtualenv named 'env' and installs all
|
# This script creates a virtualenv named 'env' and installs all
|
||||||
# python dependencies before activating the env and running Electrum.
|
# python dependencies before activating the env and running Electrum.
|
||||||
|
|
|
@ -10,7 +10,8 @@ Icon=electrum
|
||||||
Name[en_US]=Electrum Bitcoin Wallet
|
Name[en_US]=Electrum Bitcoin Wallet
|
||||||
Name=Electrum Bitcoin Wallet
|
Name=Electrum Bitcoin Wallet
|
||||||
Categories=Finance;Network;
|
Categories=Finance;Network;
|
||||||
StartupNotify=false
|
StartupNotify=true
|
||||||
|
StartupWMClass=electrum
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
MimeType=x-scheme-handler/bitcoin;
|
MimeType=x-scheme-handler/bitcoin;
|
||||||
|
|
228
run_electrum
228
run_electrum
|
@ -25,12 +25,26 @@
|
||||||
# SOFTWARE.
|
# SOFTWARE.
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import warnings
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
MIN_PYTHON_VERSION = "3.6.1" # FIXME duplicated from setup.py
|
||||||
|
_min_python_version_tuple = tuple(map(int, (MIN_PYTHON_VERSION.split("."))))
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info[:3] < _min_python_version_tuple:
|
||||||
|
sys.exit("Error: Electrum requires Python version >= %s..." % MIN_PYTHON_VERSION)
|
||||||
|
|
||||||
|
|
||||||
script_dir = os.path.dirname(os.path.realpath(__file__))
|
script_dir = os.path.dirname(os.path.realpath(__file__))
|
||||||
is_bundle = getattr(sys, 'frozen', False)
|
is_bundle = getattr(sys, 'frozen', False)
|
||||||
is_local = not is_bundle and os.path.exists(os.path.join(script_dir, "electrum.desktop"))
|
is_local = not is_bundle and os.path.exists(os.path.join(script_dir, "electrum.desktop"))
|
||||||
is_android = 'ANDROID_DATA' in os.environ
|
is_android = 'ANDROID_DATA' in os.environ
|
||||||
|
|
||||||
|
if is_local: # running from source
|
||||||
|
# developers should probably see all deprecation warnings.
|
||||||
|
warnings.simplefilter('default', DeprecationWarning)
|
||||||
|
|
||||||
# move this back to gui/kivy/__init.py once plugins are moved
|
# move this back to gui/kivy/__init.py once plugins are moved
|
||||||
os.environ['KIVY_DATA_DIR'] = os.path.abspath(os.path.dirname(__file__)) + '/electrum/gui/kivy/data/'
|
os.environ['KIVY_DATA_DIR'] = os.path.abspath(os.path.dirname(__file__)) + '/electrum/gui/kivy/data/'
|
||||||
|
|
||||||
|
@ -44,37 +58,41 @@ def check_imports():
|
||||||
import dns
|
import dns
|
||||||
import pyaes
|
import pyaes
|
||||||
import ecdsa
|
import ecdsa
|
||||||
import requests
|
import certifi
|
||||||
import qrcode
|
import qrcode
|
||||||
import google.protobuf
|
import google.protobuf
|
||||||
import jsonrpclib
|
|
||||||
import aiorpcx
|
import aiorpcx
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
sys.exit("Error: %s. Try 'sudo pip install <module-name>'"%str(e))
|
sys.exit(f"Error: {str(e)}. Try 'sudo python3 -m pip install <module-name>'")
|
||||||
# the following imports are for pyinstaller
|
# the following imports are for pyinstaller
|
||||||
from google.protobuf import descriptor
|
from google.protobuf import descriptor
|
||||||
from google.protobuf import message
|
from google.protobuf import message
|
||||||
from google.protobuf import reflection
|
from google.protobuf import reflection
|
||||||
from google.protobuf import descriptor_pb2
|
from google.protobuf import descriptor_pb2
|
||||||
from jsonrpclib import SimpleJSONRPCServer
|
|
||||||
# make sure that certificates are here
|
# make sure that certificates are here
|
||||||
assert os.path.exists(requests.utils.DEFAULT_CA_BUNDLE_PATH)
|
assert os.path.exists(certifi.where())
|
||||||
|
|
||||||
|
|
||||||
if not is_android:
|
if not is_android:
|
||||||
check_imports()
|
check_imports()
|
||||||
|
|
||||||
|
|
||||||
|
from electrum.logging import get_logger, configure_logging
|
||||||
from electrum import util
|
from electrum import util
|
||||||
from electrum import constants
|
from electrum import constants
|
||||||
from electrum import SimpleConfig
|
from electrum import SimpleConfig
|
||||||
|
from electrum.wallet_db import WalletDB
|
||||||
from electrum.wallet import Wallet
|
from electrum.wallet import Wallet
|
||||||
from electrum.storage import WalletStorage, get_derivation_used_for_hw_device_encryption
|
from electrum.storage import WalletStorage, get_derivation_used_for_hw_device_encryption
|
||||||
from electrum.util import print_msg, print_stderr, json_encode, json_decode, UserCancelled
|
from electrum.util import print_msg, print_stderr, json_encode, json_decode, UserCancelled
|
||||||
from electrum.util import set_verbosity, InvalidPassword
|
from electrum.util import InvalidPassword
|
||||||
from electrum.commands import get_parser, known_commands, Commands, config_variables
|
from electrum.commands import get_parser, known_commands, Commands, config_variables
|
||||||
from electrum import daemon
|
from electrum import daemon
|
||||||
from electrum import keystore
|
from electrum import keystore
|
||||||
|
from electrum.util import create_and_start_event_loop
|
||||||
|
|
||||||
|
_logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# get password routine
|
# get password routine
|
||||||
def prompt_password(prompt, confirm=True):
|
def prompt_password(prompt, confirm=True):
|
||||||
|
@ -89,31 +107,7 @@ def prompt_password(prompt, confirm=True):
|
||||||
return password
|
return password
|
||||||
|
|
||||||
|
|
||||||
def init_daemon(config_options):
|
def init_cmdline(config_options, wallet_path, server):
|
||||||
config = SimpleConfig(config_options)
|
|
||||||
storage = WalletStorage(config.get_wallet_path())
|
|
||||||
if not storage.file_exists():
|
|
||||||
print_msg("Error: Wallet file not found.")
|
|
||||||
print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option")
|
|
||||||
sys.exit(0)
|
|
||||||
if storage.is_encrypted():
|
|
||||||
if storage.is_encrypted_with_hw_device():
|
|
||||||
plugins = init_plugins(config, 'cmdline')
|
|
||||||
password = get_password_for_hw_device_encrypted_storage(plugins)
|
|
||||||
elif config.get('password'):
|
|
||||||
password = config.get('password')
|
|
||||||
else:
|
|
||||||
password = prompt_password('Password:', False)
|
|
||||||
if not password:
|
|
||||||
print_msg("Error: Password required")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
password = None
|
|
||||||
config_options['password'] = password
|
|
||||||
|
|
||||||
|
|
||||||
def init_cmdline(config_options, server):
|
|
||||||
config = SimpleConfig(config_options)
|
|
||||||
cmdname = config.get('cmd')
|
cmdname = config.get('cmd')
|
||||||
cmd = known_commands[cmdname]
|
cmd = known_commands[cmdname]
|
||||||
|
|
||||||
|
@ -128,12 +122,12 @@ def init_cmdline(config_options, server):
|
||||||
cmd.requires_network = True
|
cmd.requires_network = True
|
||||||
|
|
||||||
# instantiate wallet for command-line
|
# instantiate wallet for command-line
|
||||||
storage = WalletStorage(config.get_wallet_path())
|
storage = WalletStorage(wallet_path)
|
||||||
|
|
||||||
if cmd.requires_wallet and not storage.file_exists():
|
if cmd.requires_wallet and not storage.file_exists():
|
||||||
print_msg("Error: Wallet file not found.")
|
print_msg("Error: Wallet file not found.")
|
||||||
print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option")
|
print_msg("Type 'electrum create' to create a new wallet, or provide a path to a wallet with the -w option")
|
||||||
sys.exit(0)
|
sys_exit(1)
|
||||||
|
|
||||||
# important warning
|
# important warning
|
||||||
if cmd.name in ['getprivatekeys']:
|
if cmd.name in ['getprivatekeys']:
|
||||||
|
@ -141,9 +135,17 @@ def init_cmdline(config_options, server):
|
||||||
print_stderr("Exposing a single private key can compromise your entire wallet!")
|
print_stderr("Exposing a single private key can compromise your entire wallet!")
|
||||||
print_stderr("In particular, DO NOT use 'redeem private key' services proposed by third parties.")
|
print_stderr("In particular, DO NOT use 'redeem private key' services proposed by third parties.")
|
||||||
|
|
||||||
|
# will we need a password
|
||||||
|
if not storage.is_encrypted():
|
||||||
|
db = WalletDB(storage.read(), manual_upgrades=False)
|
||||||
|
use_encryption = db.get('use_encryption')
|
||||||
|
else:
|
||||||
|
use_encryption = True
|
||||||
|
|
||||||
# commands needing password
|
# commands needing password
|
||||||
if (cmd.requires_wallet and storage.is_encrypted() and server is None)\
|
if ( (cmd.requires_wallet and storage.is_encrypted() and server is False)\
|
||||||
or (cmd.requires_password and (storage.get('use_encryption') or storage.is_encrypted())):
|
or (cmdname == 'load_wallet' and storage.is_encrypted())\
|
||||||
|
or (cmd.requires_password and use_encryption)):
|
||||||
if storage.is_encrypted_with_hw_device():
|
if storage.is_encrypted_with_hw_device():
|
||||||
# this case is handled later in the control flow
|
# this case is handled later in the control flow
|
||||||
password = None
|
password = None
|
||||||
|
@ -153,7 +155,7 @@ def init_cmdline(config_options, server):
|
||||||
password = prompt_password('Password:', False)
|
password = prompt_password('Password:', False)
|
||||||
if not password:
|
if not password:
|
||||||
print_msg("Error: Password required")
|
print_msg("Error: Password required")
|
||||||
sys.exit(1)
|
sys_exit(1)
|
||||||
else:
|
else:
|
||||||
password = None
|
password = None
|
||||||
|
|
||||||
|
@ -173,18 +175,18 @@ def get_connected_hw_devices(plugins):
|
||||||
name, plugin = splugin.name, splugin.plugin
|
name, plugin = splugin.name, splugin.plugin
|
||||||
if not plugin:
|
if not plugin:
|
||||||
e = splugin.exception
|
e = splugin.exception
|
||||||
print_stderr(f"{name}: error during plugin init: {repr(e)}")
|
_logger.error(f"{name}: error during plugin init: {repr(e)}")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
u = devmgr.unpaired_device_infos(None, plugin)
|
u = devmgr.unpaired_device_infos(None, plugin)
|
||||||
except:
|
except Exception as e:
|
||||||
devmgr.print_error(f'error getting device infos for {name}: {e}')
|
_logger.error(f'error getting device infos for {name}: {repr(e)}')
|
||||||
continue
|
continue
|
||||||
devices += list(map(lambda x: (name, x), u))
|
devices += list(map(lambda x: (name, x), u))
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def get_password_for_hw_device_encrypted_storage(plugins):
|
def get_password_for_hw_device_encrypted_storage(plugins) -> str:
|
||||||
devices = get_connected_hw_devices(plugins)
|
devices = get_connected_hw_devices(plugins)
|
||||||
if len(devices) == 0:
|
if len(devices) == 0:
|
||||||
print_msg("Error: No connected hw device found. Cannot decrypt this wallet.")
|
print_msg("Error: No connected hw device found. Cannot decrypt this wallet.")
|
||||||
|
@ -200,14 +202,16 @@ def get_password_for_hw_device_encrypted_storage(plugins):
|
||||||
xpub = plugin.get_xpub(device_info.device.id_, derivation, 'standard', plugin.handler)
|
xpub = plugin.get_xpub(device_info.device.id_, derivation, 'standard', plugin.handler)
|
||||||
except UserCancelled:
|
except UserCancelled:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
password = keystore.Xpub.get_pubkey_from_xpub(xpub, ())
|
password = keystore.Xpub.get_pubkey_from_xpub(xpub, ()).hex()
|
||||||
return password
|
return password
|
||||||
|
|
||||||
|
|
||||||
def run_offline_command(config, config_options, plugins):
|
async def run_offline_command(config, config_options, plugins):
|
||||||
cmdname = config.get('cmd')
|
cmdname = config.get('cmd')
|
||||||
cmd = known_commands[cmdname]
|
cmd = known_commands[cmdname]
|
||||||
password = config_options.get('password')
|
password = config_options.get('password')
|
||||||
|
if 'wallet_path' in cmd.options and config_options.get('wallet_path') is None:
|
||||||
|
config_options['wallet_path'] = config.get_wallet_path()
|
||||||
if cmd.requires_wallet:
|
if cmd.requires_wallet:
|
||||||
storage = WalletStorage(config.get_wallet_path())
|
storage = WalletStorage(config.get_wallet_path())
|
||||||
if storage.is_encrypted():
|
if storage.is_encrypted():
|
||||||
|
@ -215,7 +219,9 @@ def run_offline_command(config, config_options, plugins):
|
||||||
password = get_password_for_hw_device_encrypted_storage(plugins)
|
password = get_password_for_hw_device_encrypted_storage(plugins)
|
||||||
config_options['password'] = password
|
config_options['password'] = password
|
||||||
storage.decrypt(password)
|
storage.decrypt(password)
|
||||||
wallet = Wallet(storage)
|
db = WalletDB(storage.read(), manual_upgrades=False)
|
||||||
|
wallet = Wallet(db, storage, config=config)
|
||||||
|
config_options['wallet'] = wallet
|
||||||
else:
|
else:
|
||||||
wallet = None
|
wallet = None
|
||||||
# check password
|
# check password
|
||||||
|
@ -235,13 +241,13 @@ def run_offline_command(config, config_options, plugins):
|
||||||
# options
|
# options
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
for x in cmd.options:
|
for x in cmd.options:
|
||||||
kwargs[x] = (config_options.get(x) if x in ['password', 'new_password'] else config.get(x))
|
kwargs[x] = (config_options.get(x) if x in ['wallet_path', 'wallet', 'password', 'new_password'] else config.get(x))
|
||||||
cmd_runner = Commands(config, wallet, None)
|
cmd_runner = Commands(config=config)
|
||||||
func = getattr(cmd_runner, cmd.name)
|
func = getattr(cmd_runner, cmd.name)
|
||||||
result = func(*args, **kwargs)
|
result = await func(*args, **kwargs)
|
||||||
# save wallet
|
# save wallet
|
||||||
if wallet:
|
if wallet:
|
||||||
wallet.storage.write()
|
wallet.save_db()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@ -249,6 +255,11 @@ def init_plugins(config, gui_name):
|
||||||
from electrum.plugin import Plugins
|
from electrum.plugin import Plugins
|
||||||
return Plugins(config, gui_name)
|
return Plugins(config, gui_name)
|
||||||
|
|
||||||
|
def sys_exit(i):
|
||||||
|
# stop event loop and exit
|
||||||
|
loop.call_soon_threadsafe(stop_loop.set_result, 1)
|
||||||
|
loop_thread.join(timeout=1)
|
||||||
|
sys.exit(i)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# The hook will only be used in the Qt GUI right now
|
# The hook will only be used in the Qt GUI right now
|
||||||
|
@ -262,6 +273,9 @@ if __name__ == '__main__':
|
||||||
sys.argv.append('-h')
|
sys.argv.append('-h')
|
||||||
|
|
||||||
# old '-v' syntax
|
# old '-v' syntax
|
||||||
|
# Due to this workaround that keeps old -v working,
|
||||||
|
# more advanced usages of -v need to use '-v='.
|
||||||
|
# e.g. -v=debug,network=warning,interface=error
|
||||||
try:
|
try:
|
||||||
i = sys.argv.index('-v')
|
i = sys.argv.index('-v')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -309,8 +323,8 @@ if __name__ == '__main__':
|
||||||
if config_options.get('portable'):
|
if config_options.get('portable'):
|
||||||
config_options['electrum_path'] = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'electrum_data')
|
config_options['electrum_path'] = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'electrum_data')
|
||||||
|
|
||||||
# kivy sometimes freezes when we write to sys.stderr
|
if not config_options.get('verbosity'):
|
||||||
set_verbosity(config_options.get('verbosity') if config_options.get('gui') != 'kivy' else '')
|
warnings.simplefilter('ignore', DeprecationWarning)
|
||||||
|
|
||||||
# check uri
|
# check uri
|
||||||
uri = config_options.get('url')
|
uri = config_options.get('url')
|
||||||
|
@ -318,11 +332,9 @@ if __name__ == '__main__':
|
||||||
if not uri.startswith('bitcoin:'):
|
if not uri.startswith('bitcoin:'):
|
||||||
print_stderr('unknown command:', uri)
|
print_stderr('unknown command:', uri)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
config_options['url'] = uri
|
|
||||||
|
|
||||||
# todo: defer this to gui
|
# singleton
|
||||||
config = SimpleConfig(config_options)
|
config = SimpleConfig(config_options)
|
||||||
cmdname = config.get('cmd')
|
|
||||||
|
|
||||||
if config.get('testnet'):
|
if config.get('testnet'):
|
||||||
constants.set_testnet()
|
constants.set_testnet()
|
||||||
|
@ -331,70 +343,86 @@ if __name__ == '__main__':
|
||||||
elif config.get('simnet'):
|
elif config.get('simnet'):
|
||||||
constants.set_simnet()
|
constants.set_simnet()
|
||||||
|
|
||||||
if cmdname == 'gui':
|
cmdname = config.get('cmd')
|
||||||
fd, server = daemon.get_fd_or_server(config)
|
|
||||||
if fd is not None:
|
|
||||||
plugins = init_plugins(config, config.get('gui', 'qt'))
|
|
||||||
d = daemon.Daemon(config, fd)
|
|
||||||
d.init_gui(config, plugins)
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
result = server.gui(config_options)
|
|
||||||
|
|
||||||
elif cmdname == 'daemon':
|
if cmdname == 'daemon' and config.get("detach"):
|
||||||
subcommand = config.get('subcommand')
|
# fork before creating the asyncio event loop
|
||||||
if subcommand in ['load_wallet']:
|
|
||||||
init_daemon(config_options)
|
|
||||||
|
|
||||||
if subcommand in [None, 'start']:
|
|
||||||
fd, server = daemon.get_fd_or_server(config)
|
|
||||||
if fd is not None:
|
|
||||||
if subcommand == 'start':
|
|
||||||
pid = os.fork()
|
pid = os.fork()
|
||||||
if pid:
|
if pid:
|
||||||
print_stderr("starting daemon (PID %d)" % pid)
|
print_stderr("starting daemon (PID %d)" % pid)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
# redirect standard file descriptors
|
||||||
|
sys.stdout.flush()
|
||||||
|
sys.stderr.flush()
|
||||||
|
si = open(os.devnull, 'r')
|
||||||
|
so = open(os.devnull, 'w')
|
||||||
|
se = open(os.devnull, 'w')
|
||||||
|
os.dup2(si.fileno(), sys.stdin.fileno())
|
||||||
|
os.dup2(so.fileno(), sys.stdout.fileno())
|
||||||
|
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||||
|
|
||||||
|
loop, stop_loop, loop_thread = create_and_start_event_loop()
|
||||||
|
|
||||||
|
if cmdname == 'gui':
|
||||||
|
configure_logging(config)
|
||||||
|
fd = daemon.get_file_descriptor(config)
|
||||||
|
if fd is not None:
|
||||||
|
plugins = init_plugins(config, config.get('gui', 'qt'))
|
||||||
|
d = daemon.Daemon(config, fd)
|
||||||
|
d.run_gui(config, plugins)
|
||||||
|
sys_exit(0)
|
||||||
|
else:
|
||||||
|
result = daemon.request(config, 'gui', (config_options,))
|
||||||
|
|
||||||
|
elif cmdname == 'daemon':
|
||||||
|
|
||||||
|
configure_logging(config)
|
||||||
|
fd = daemon.get_file_descriptor(config)
|
||||||
|
if fd is not None:
|
||||||
|
# run daemon
|
||||||
init_plugins(config, 'cmdline')
|
init_plugins(config, 'cmdline')
|
||||||
d = daemon.Daemon(config, fd)
|
d = daemon.Daemon(config, fd)
|
||||||
if config.get('websocket_server'):
|
d.run_daemon()
|
||||||
from electrum import websockets
|
sys_exit(0)
|
||||||
websockets.WebSocketServer(config, d.network)
|
|
||||||
if config.get('requests_dir'):
|
|
||||||
path = os.path.join(config.get('requests_dir'), 'index.html')
|
|
||||||
if not os.path.exists(path):
|
|
||||||
print("Requests directory not configured.")
|
|
||||||
print("You can configure it using https://github.com/spesmilo/electrum-merchant")
|
|
||||||
sys.exit(1)
|
|
||||||
d.join()
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
else:
|
||||||
result = server.daemon(config_options)
|
print_msg("Daemon already running")
|
||||||
else:
|
sys_exit(1)
|
||||||
server = daemon.get_server(config)
|
|
||||||
if server is not None:
|
|
||||||
result = server.daemon(config_options)
|
|
||||||
else:
|
|
||||||
print_msg("Daemon not running")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
else:
|
||||||
# command line
|
# command line
|
||||||
server = daemon.get_server(config)
|
|
||||||
init_cmdline(config_options, server)
|
|
||||||
if server is not None:
|
|
||||||
result = server.run_cmdline(config_options)
|
|
||||||
else:
|
|
||||||
cmd = known_commands[cmdname]
|
cmd = known_commands[cmdname]
|
||||||
if cmd.requires_network:
|
wallet_path = config.get_wallet_path()
|
||||||
print_msg("Daemon not running; try 'electrum daemon start'")
|
if not config.get('offline'):
|
||||||
sys.exit(1)
|
init_cmdline(config_options, wallet_path, True)
|
||||||
|
timeout = config.get('timeout', 60)
|
||||||
|
if timeout: timeout = int(timeout)
|
||||||
|
try:
|
||||||
|
result = daemon.request(config, 'run_cmdline', (config_options,), timeout)
|
||||||
|
except daemon.DaemonNotRunning:
|
||||||
|
print_msg("Daemon not running; try 'electrum daemon -d'")
|
||||||
|
if not cmd.requires_network:
|
||||||
|
print_msg("To run this command without a daemon, use --offline")
|
||||||
|
sys_exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print_stderr(str(e) or repr(e))
|
||||||
|
sys_exit(1)
|
||||||
else:
|
else:
|
||||||
|
if cmd.requires_network:
|
||||||
|
print_msg("This command cannot be run offline")
|
||||||
|
sys_exit(1)
|
||||||
|
init_cmdline(config_options, wallet_path, False)
|
||||||
plugins = init_plugins(config, 'cmdline')
|
plugins = init_plugins(config, 'cmdline')
|
||||||
result = run_offline_command(config, config_options, plugins)
|
coro = run_offline_command(config, config_options, plugins)
|
||||||
# print result
|
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||||
|
try:
|
||||||
|
result = fut.result()
|
||||||
|
except Exception as e:
|
||||||
|
print_stderr(str(e) or repr(e))
|
||||||
|
sys_exit(1)
|
||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
print_msg(result)
|
print_msg(result)
|
||||||
elif type(result) is dict and result.get('error'):
|
elif type(result) is dict and result.get('error'):
|
||||||
print_stderr(result.get('error'))
|
print_stderr(result.get('error'))
|
||||||
elif result is not None:
|
elif result is not None:
|
||||||
print_msg(json_encode(result))
|
print_msg(json_encode(result))
|
||||||
sys.exit(0)
|
sys_exit(0)
|
||||||
|
|
28
setup.py
28
setup.py
|
@ -17,7 +17,7 @@ _min_python_version_tuple = tuple(map(int, (MIN_PYTHON_VERSION.split("."))))
|
||||||
|
|
||||||
|
|
||||||
if sys.version_info[:3] < _min_python_version_tuple:
|
if sys.version_info[:3] < _min_python_version_tuple:
|
||||||
sys.exit("Error: Electrum requires Python version >= {}...".format(MIN_PYTHON_VERSION))
|
sys.exit("Error: Electrum requires Python version >= %s..." % MIN_PYTHON_VERSION)
|
||||||
|
|
||||||
with open('contrib/requirements/requirements.txt') as f:
|
with open('contrib/requirements/requirements.txt') as f:
|
||||||
requirements = f.read().splitlines()
|
requirements = f.read().splitlines()
|
||||||
|
@ -47,34 +47,16 @@ if platform.system() in ['Linux', 'FreeBSD', 'DragonFly']:
|
||||||
usr_share = os.path.expanduser('~/.local/share')
|
usr_share = os.path.expanduser('~/.local/share')
|
||||||
data_files += [
|
data_files += [
|
||||||
(os.path.join(usr_share, 'applications/'), ['electrum.desktop']),
|
(os.path.join(usr_share, 'applications/'), ['electrum.desktop']),
|
||||||
(os.path.join(usr_share, icons_dirname), ['icons/electrum.png'])
|
(os.path.join(usr_share, icons_dirname), ['electrum/gui/icons/electrum.png']),
|
||||||
]
|
]
|
||||||
|
|
||||||
extras_require = {
|
extras_require = {
|
||||||
'hardware': requirements_hw,
|
'hardware': requirements_hw,
|
||||||
'fast': ['pycryptodomex'],
|
|
||||||
'gui': ['pyqt5'],
|
'gui': ['pyqt5'],
|
||||||
}
|
}
|
||||||
extras_require['full'] = [pkg for sublist in list(extras_require.values()) for pkg in sublist]
|
extras_require['full'] = [pkg for sublist in list(extras_require.values()) for pkg in sublist]
|
||||||
|
|
||||||
|
|
||||||
class CustomInstallCommand(install):
|
|
||||||
def run(self):
|
|
||||||
install.run(self)
|
|
||||||
# potentially build Qt icons file
|
|
||||||
try:
|
|
||||||
import PyQt5
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
path = os.path.join(self.install_lib, "electrum/gui/qt/icons_rc.py")
|
|
||||||
if not os.path.exists(path):
|
|
||||||
subprocess.call(["pyrcc5", "icons.qrc", "-o", path])
|
|
||||||
except Exception as e:
|
|
||||||
print('Warning: building icons file failed with {}'.format(e))
|
|
||||||
|
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="Electrum",
|
name="Electrum",
|
||||||
version=version.ELECTRUM_VERSION,
|
version=version.ELECTRUM_VERSION,
|
||||||
|
@ -96,6 +78,9 @@ setup(
|
||||||
'wordlist/*.txt',
|
'wordlist/*.txt',
|
||||||
'locale/*/LC_MESSAGES/electrum.mo',
|
'locale/*/LC_MESSAGES/electrum.mo',
|
||||||
],
|
],
|
||||||
|
'electrum.gui': [
|
||||||
|
'icons/*',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
scripts=['electrum/electrum'],
|
scripts=['electrum/electrum'],
|
||||||
data_files=data_files,
|
data_files=data_files,
|
||||||
|
@ -105,7 +90,4 @@ setup(
|
||||||
license="MIT Licence",
|
license="MIT Licence",
|
||||||
url="https://electrum.org",
|
url="https://electrum.org",
|
||||||
long_description="""Lightweight Bitcoin Wallet""",
|
long_description="""Lightweight Bitcoin Wallet""",
|
||||||
cmdclass={
|
|
||||||
'install': CustomInstallCommand,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Reference in a new issue