Create Specter Desktop tray app (#273)

* Add basic tray app

* Run specterd from release

* Start on launch

* Support remote node

* Auto whitelist remote node url

* Windows related fixes

* fixes

* pyinstaller on windows

* update instructions

* update pyinstaller readme

Co-authored-by: Stepan Snigirev <snigirev.stepan@gmail.com>
This commit is contained in:
benk10 2020-08-24 15:28:00 +02:00 committed by GitHub
parent 5699a5fb06
commit 2a799dde13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 669 additions and 12 deletions

2
.gitignore vendored
View file

@ -10,3 +10,5 @@ dist
.vscode
cert.pem
key.pem
*.dmg
pyinstaller/specterd/*

View file

@ -1,11 +1,74 @@
# Pyinstaller build
Install `pyinstaller`:
`cd` into this directory (`specter-desktop/pyinstaller`) and install requirements:
```bash
$ pip3 install pyinstaller
$ pip3 install -r requirements.txt
```
`cd` into this directory (`specter-desktop/pyinstaller`) and run:
Now run:
```bash
$ pyinstaller --onefile specterd.spec
$ pyinstaller specterd.spec
```
And for HWIBridge, run:
```bash
pyinstaller hwibridge.spec
```
# Building Specter launcher (tray app)
## Creating a Windows setup file
From Powershell:
1. Build `specterd` and `hwibridge` in onedir mode:
```bash
pyinstaller specterd_onedir.spec
pyinstaller hwibridge_onedir.spec
```
You should get two folders in the `dist` folder: `specterd` and `hwibridge`.
2. Copy `specterd` folder from `dist` folder to this directory.
3. Copy `hwibridge.exe` and `hwibridge.exe.manifest` from `dist\hwibridge\` to `specterd` folder.
4. Run `pyinstaller specter_desktop.spec` - this should create a `specter_desktop` folder in the `dist` directory. Check that it works by running `dist\specter_desktop\specter_desktop.exe`
5. Create an installer using [InnoSetup](https://jrsoftware.org/isdl.php#stable), select `dist\specter_desktop\specter_desktop.exe` as main executable, add `dist\specter_desktop` folder to the setup wizard as well.
## Creating a DMG for macOS
1. Build `specterd` and `hwibridge` in onedir mode:
```bash
pyinstaller specterd_onedir.spec
pyinstaller hwibridge_onedir.spec
```
You should get two folders in the `dist` folder: `specterd` and `hwibridge`.
2. Copy `specterd` folder from `dist` folder to this directory: `cp -r dist/specterd/ ./specterd`
3. Copy `hwibridge` binary from `dist/hwibridge` to `specterd` folder: `cp dist/hwibridge/hwibridge specterd`.
4. Now in the terminal, run `pyinstaller specter_desktop.spec` (you might need to use sudo). This should create a new Specter and Specter.app files.
5. The `Specter.app` file is the executable macOS app we will need to package now as a `.dmg` for distribution.
6. Make sure you have [`NPM`](https://www.npmjs.com/get-npm) installed, and run `npm install --global create-dmg`.
7. Now run `create-dmg 'dist/Specter.app'`. This should generate a new `Specter 0.0.0.dmg`.
8. The `.dmg` should now be ready to use! Note: You can rename the `.dmg` file to have the proper version (or just say `Specter`).
## Creating a binary for Linux
1. Build `specterd` and `hwibridge` in onedir mode:
```bash
pyinstaller specterd_onedir.spec
pyinstaller hwibridge_onedir.spec
```
You should get two folders in the `dist` folder: `specterd` and `hwibridge`.
2. Copy `specterd` folder from `dist` folder to this directory: `cp -r dist/specterd/ ./specterd`
3. Copy `hwibridge` binary from `dist/hwibridge` to `specterd` folder: `cp dist/hwibridge/hwibridge specterd`.
4. Run `pyinstaller specter_desktop.spec`. This should create a Specter executable.

View file

@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
import platform
import subprocess
import mnemonic, os
mnemonic_path = os.path.join(mnemonic.__path__[0], "wordlist")
block_cipher = None
binaries = []
if platform.system() == 'Windows':
binaries = [("./windll/libusb-1.0.dll", ".")]
elif platform.system() == 'Linux':
if platform.processor() == 'aarch64': #ARM 64 bit
binaries = [("/lib/aarch64-linux-gnu/libusb-1.0.so.0", ".")]
else:
binaries = [("/lib/x86_64-linux-gnu/libusb-1.0.so.0", ".")]
elif platform.system() == 'Darwin':
find_brew_libusb_proc = subprocess.Popen(['brew', '--prefix', 'libusb'], stdout=subprocess.PIPE)
libusb_path = find_brew_libusb_proc.communicate()[0]
binaries = [(libusb_path.rstrip().decode() + "/lib/libusb-1.0.dylib", ".")]
a = Analysis(['hwibridge.py'],
binaries=binaries,
datas=[('../src/cryptoadvance/specter/templates', 'templates'),
('../src/cryptoadvance/specter/static', 'static'),
(mnemonic_path, 'mnemonic/wordlist'),
],
hiddenimports=[
'pkg_resources.py2_warn',
'cryptoadvance.specter.config'
],
hookspath=['hooks/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
if platform.system() == 'Linux':
import hwilib
a.datas += Tree('../udev', prefix='hwilib/udev')
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='hwibridge',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='hwibridge')

View file

@ -0,0 +1,2 @@
pyinstaller
PyQt5

View file

@ -0,0 +1,334 @@
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon, QMenu, QAction, \
QDialog, QDialogButtonBox, QVBoxLayout, QRadioButton, QLineEdit
from PyQt5.QtCore import QRunnable, QThreadPool, QSettings
import sys
import os
import subprocess
import webbrowser
import json
import platform
from cryptoadvance.specter.config import DATA_FOLDER
from cryptoadvance.specter.helpers import deep_update
running = True
path = os.path.dirname(os.path.abspath(__file__))
is_specterd_running = False
specterd_thread = None
settings = QSettings('cryptoadvance', 'specter')
wait_for_specterd_process = None
def resource_path(relative_path):
try:
base_path = sys._MEIPASS
except Exception:
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
class SpecterPreferencesDialog(QDialog):
global settings
def __init__(self, *args, **kwargs):
super(SpecterPreferencesDialog, self).__init__(*args, **kwargs)
self.setWindowTitle("Specter Preferences")
self.layout = QVBoxLayout()
QBtn = QDialogButtonBox.Save | QDialogButtonBox.Cancel
self.buttonBox = QDialogButtonBox(QBtn)
self.buttonBox.accepted.connect(self.accept)
self.buttonBox.rejected.connect(self.reject)
# Mode setting
self.mode_local = QRadioButton("Run Local Specter Server")
self.mode_local.toggled.connect(self.toggle_mode)
self.mode_remote = QRadioButton("Use a Remote Specter Server")
self.mode_remote.toggled.connect(self.toggle_mode)
self.specter_url = QLineEdit(
placeholderText="Please enter the remote Specter URL"
)
is_remote_mode = settings.value(
'remote_mode',
defaultValue=False,
type=bool
)
if is_remote_mode:
self.mode_remote.setChecked(True)
else:
self.mode_local.setChecked(True)
self.specter_url.hide()
settings.setValue('remote_mode_temp', is_remote_mode)
remote_specter_url = settings.value(
'specter_url',
defaultValue='',
type=str
) if is_remote_mode else ''
settings.setValue('specter_url_temp', remote_specter_url)
self.specter_url.setText(remote_specter_url)
self.specter_url.textChanged.connect(
lambda: settings.setValue(
'specter_url_temp',
self.specter_url.text()
)
)
self.layout.addWidget(self.mode_local)
self.layout.addWidget(self.mode_remote)
self.layout.addWidget(self.specter_url)
self.layout.addWidget(self.buttonBox)
self.resize(500, 180)
self.setLayout(self.layout)
def toggle_mode(self):
if self.mode_local.isChecked():
settings.setValue('remote_mode_temp', False)
self.specter_url.hide()
else:
settings.setValue('remote_mode_temp', True)
self.specter_url.show()
class ProcessRunnable(QRunnable):
def __init__(self, target, args):
QRunnable.__init__(self)
self.t = target
self.args = args
def run(self):
self.t(*self.args)
def start(self):
QThreadPool.globalInstance().start(self)
def wait_for_specterd(menu):
global specterd_thread, running
start_specterd_menu = menu.actions()[0]
start_specterd_menu.setEnabled(False)
start_specterd_menu.setText('Starting up Specter{} daemon...'.format(
' HWIBridge' if settings.value(
"remote_mode", defaultValue=False, type=bool
) else ''
))
while running:
line = specterd_thread.stdout.readline()
if b'Serving Flask app' in line:
print("* Started Specter daemon...")
start_specterd_menu.setText('Specter{} daemon is running'.format(
' HWIBridge' if settings.value(
"remote_mode", defaultValue=False, type=bool
) else ''
))
toggle_specterd_status(menu)
open_specter_window()
return
elif b'Failed' in line or b'Error' in line:
start_specterd_menu.setText('Start Specter daemon'.format(
' HWIBridge' if settings.value(
"remote_mode", defaultValue=False, type=bool
) else ''
))
start_specterd_menu.setEnabled(True)
return
def run_specterd(menu):
global specterd_thread, wait_for_specterd_process
try:
specterd_command = [
os.path.join(
resource_path('specterd'),
'{}{}'.format(
'hwibridge' if settings.value(
"remote_mode", defaultValue=False, type=bool
) else 'specterd',
'.exe' if platform.system() == "Windows" else '')
)
]
specterd_thread = subprocess.Popen(
specterd_command,
stdout=subprocess.PIPE
)
wait_for_specterd_process = ProcessRunnable(
target=wait_for_specterd,
args=(menu, )
)
wait_for_specterd_process.start()
except Exception as e:
print("* Failed to start Specter daemon {}".format(e))
def stop_specterd(menu):
global specterd_thread
try:
if specterd_thread:
specterd_thread.terminate()
except Exception as e:
print(e)
print("* Stopped Specter daemon")
toggle_specterd_status(menu)
def open_specter_window():
global settings
webbrowser.open(settings.value("specter_url", type=str), new=1)
def toggle_specterd_status(menu):
global is_specterd_running
start_specterd_menu = menu.actions()[0]
stop_specterd_menu = menu.actions()[1]
open_specter_menu = menu.actions()[2]
if is_specterd_running:
start_specterd_menu.setEnabled(False)
stop_specterd_menu.setEnabled(True)
open_specter_menu.setEnabled(True)
else:
start_specterd_menu.setText('Start Specter{} daemon'.format(
' HWIBridge' if settings.value(
"remote_mode", defaultValue=False, type=bool
) else ''
))
start_specterd_menu.setEnabled(True)
stop_specterd_menu.setEnabled(False)
open_specter_menu.setEnabled(False)
is_specterd_running = not is_specterd_running
def quit_specter(app):
global running
running = False
if specterd_thread:
specterd_thread.terminate()
app.quit()
def open_settings():
global settings
dlg = SpecterPreferencesDialog()
if dlg.exec_():
is_remote_mode = settings.value(
'remote_mode_temp',
defaultValue=False,
type=bool
)
settings.setValue(
'remote_mode',
is_remote_mode
)
specter_url_temp = settings.value(
'specter_url_temp',
defaultValue='http://localhost:25441/',
type=str
)
settings.setValue(
'specter_url',
specter_url_temp if is_remote_mode else 'http://localhost:25441/'
)
hwibridge_settings_path = os.path.join(
os.path.expanduser(DATA_FOLDER),
"hwi_bridge_config.json"
)
if is_remote_mode:
config = {
'whitelisted_domains': 'http://127.0.0.1:25441/'
}
if os.path.isfile(hwibridge_settings_path):
with open(hwibridge_settings_path, "r") as f:
file_config = json.loads(f.read())
deep_update(config, file_config)
with open(hwibridge_settings_path, "w") as f:
if 'whitelisted_domains' in config:
whitelisted_domains = ''
if specter_url_temp not in config[
'whitelisted_domains'
].split():
config['whitelisted_domains'] += ' ' + specter_url_temp
for url in config['whitelisted_domains'].split():
if not url.endswith("/") and url != '*':
# make sure the url end with a "/"
url += "/"
whitelisted_domains += url.strip() + '\n'
config['whitelisted_domains'] = whitelisted_domains
f.write(json.dumps(config, indent=4))
# TODO: Add PORT setting
def init_desktop_app():
global settings
app = QApplication([])
app.setQuitOnLastWindowClosed(False)
# Create the icon
icon = QIcon(os.path.join(
resource_path('specterd'),
'static/img/icon.png'
))
# Create the tray
tray = QSystemTrayIcon()
tray.setIcon(icon)
tray.setVisible(True)
# Create the menu
menu = QMenu()
start_specterd_menu = QAction("Start Specter{} daemon".format(
' HWIBridge' if settings.value(
"remote_mode", defaultValue=False, type=bool
) else ''
))
stop_specterd_menu = QAction("Stop Specter daemon")
open_specter_menu = QAction("Open Specter")
start_specterd_menu.triggered.connect(lambda: run_specterd(menu))
menu.addAction(start_specterd_menu)
stop_specterd_menu.triggered.connect(lambda: stop_specterd(menu))
menu.addAction(stop_specterd_menu)
open_specter_menu.triggered.connect(open_specter_window)
menu.addAction(open_specter_menu)
toggle_specterd_status(menu)
open_settings_menu = QAction("Preferences")
open_settings_menu.triggered.connect(open_settings)
menu.addAction(open_settings_menu)
# Add a Quit option to the menu.
quit = QAction("Quit")
quit.triggered.connect(lambda: quit_specter(app))
menu.addAction(quit)
# Add the menu to the tray
tray.setContextMenu(menu)
app.setWindowIcon(icon)
# Setup settings
if settings.value('first_time', defaultValue=True, type=bool):
settings.setValue('first_time', False)
settings.setValue('remote_mode', False)
settings.setValue('specter_url', 'http://localhost:25441/')
open_settings()
run_specterd(menu)
sys.exit(app.exec_())
if __name__ == "__main__":
init_desktop_app()

View file

@ -0,0 +1,117 @@
# -*- mode: python ; coding: utf-8 -*-
import platform
import subprocess
import mnemonic, os, sys
mnemonic_path = os.path.join(mnemonic.__path__[0], "wordlist")
block_cipher = None
binaries = []
if platform.system() == 'Windows':
binaries = [("./windll/libusb-1.0.dll", ".")]
elif platform.system() == 'Linux':
if platform.processor() == 'aarch64': #ARM 64 bit
binaries = [("/lib/aarch64-linux-gnu/libusb-1.0.so.0", ".")]
else:
binaries = [("/lib/x86_64-linux-gnu/libusb-1.0.so.0", ".")]
elif platform.system() == 'Darwin':
find_brew_libusb_proc = subprocess.Popen(['brew', '--prefix', 'libusb'], stdout=subprocess.PIPE)
libusb_path = find_brew_libusb_proc.communicate()[0]
binaries = [(libusb_path.rstrip().decode() + "/lib/libusb-1.0.dylib", ".")]
a = Analysis(['specter_desktop.py'],
binaries=binaries,
datas=[('../src/cryptoadvance/specter/templates', 'templates'),
('../src/cryptoadvance/specter/static', 'static'),
('./specterd', 'specterd'),
(mnemonic_path, 'mnemonic/wordlist'),
],
hiddenimports=[
'pkg_resources.py2_warn',
'cryptoadvance.specter.config'
],
hookspath=['hooks/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
if platform.system() == 'Linux':
import hwilib
a.datas += Tree('../udev', prefix='hwilib/udev')
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
if sys.platform == 'darwin':
exe = EXE(pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name='Specter',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True )
app = BUNDLE(
exe,
name='Specter.app',
icon='../src/cryptoadvance/specter/static/img/icon.icns',
bundle_identifier=None,
info_plist={
'NSPrincipleClass': 'NSApplication',
'NSAppleScriptEnabled': False,
'NSHighResolutionCapable': 'True',
'NSRequiresAquaSystemAppearance': 'True',
'LSUIElement': 1
}
)
if sys.platform == 'linux':
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
name='Specter',
debug=False,
strip=False,
upx=True,
runtime_tmpdir=None,
console=False,
icon='../src/cryptoadvance/specter/static/img/icon.ico'
)
if sys.platform == 'win32' or sys.platform == 'win64':
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='specter_desktop',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
icon='../src/cryptoadvance/specter/static/img/icon.ico' )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
console=False,
name='specter_desktop')

View file

@ -0,0 +1,65 @@
# -*- mode: python ; coding: utf-8 -*-
import platform
import subprocess
import mnemonic, os
mnemonic_path = os.path.join(mnemonic.__path__[0], "wordlist")
block_cipher = None
binaries = []
if platform.system() == 'Windows':
binaries = [("./windll/libusb-1.0.dll", ".")]
elif platform.system() == 'Linux':
if platform.processor() == 'aarch64': #ARM 64 bit
binaries = [("/lib/aarch64-linux-gnu/libusb-1.0.so.0", ".")]
else:
binaries = [("/lib/x86_64-linux-gnu/libusb-1.0.so.0", ".")]
elif platform.system() == 'Darwin':
find_brew_libusb_proc = subprocess.Popen(['brew', '--prefix', 'libusb'], stdout=subprocess.PIPE)
libusb_path = find_brew_libusb_proc.communicate()[0]
binaries = [(libusb_path.rstrip().decode() + "/lib/libusb-1.0.dylib", ".")]
a = Analysis(['specterd.py'],
binaries=binaries,
datas=[('../src/cryptoadvance/specter/templates', 'templates'),
('../src/cryptoadvance/specter/static', 'static'),
(mnemonic_path, 'mnemonic/wordlist'),
],
hiddenimports=[
'pkg_resources.py2_warn',
'cryptoadvance.specter.config'
],
hookspath=['hooks/'],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
if platform.system() == 'Linux':
import hwilib
a.datas += Tree('../udev', prefix='hwilib/udev')
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='specterd',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False )
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='specterd')

View file

@ -11,7 +11,8 @@ import docker
from .bitcoind import (BitcoindDockerController,
fetch_wallet_addresses_for_mining)
from .server import DATA_FOLDER, create_app, init_app
from .server import create_app, init_app
from .config import DATA_FOLDER
from os import path
import signal

View file

@ -12,6 +12,8 @@ except ImportError:
# Python 3
import configparser
DATA_FOLDER = "~/.specter"
# BASEDIR = os.path.abspath(os.path.dirname(__file__))
# Loading env-vars from .flaskenv (4 levels above this file)

View file

@ -11,13 +11,13 @@ from .helpers import hwi_get_config
from .specter import Specter
from .hwi_server import hwi_server
from .user import User
from .config import DATA_FOLDER
logger = logging.getLogger(__name__)
env_path = Path('.') / '.flaskenv'
load_dotenv(env_path)
DATA_FOLDER = "~/.specter"
def create_app(config="cryptoadvance.specter.config.DevelopmentConfig"):
# Enables injection of a different config via Env-Variable
@ -33,7 +33,11 @@ def create_app(config="cryptoadvance.specter.config.DevelopmentConfig"):
logger.info("pyinstaller based instance running in {}".format(sys._MEIPASS))
app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
else:
app = Flask(__name__, template_folder="templates", static_folder="static")
app = Flask(
__name__,
template_folder="templates",
static_folder="static"
)
app.config.from_object(config)
return app
@ -43,21 +47,22 @@ def init_app(app, hwibridge=False, specter=None):
app.logger.info("Initializing QRcode")
# Login via Flask-Login
app.logger.info("Initializing LoginManager")
if specter == None:
if specter is None:
# the default. If not None, then it got injected for testing
app.logger.info("Initializing Specter")
specter = Specter(DATA_FOLDER)
login_manager = LoginManager()
login_manager.init_app(app) # Enable Login
login_manager.login_view = "login" # Enable redirects if unauthorized
login_manager.init_app(app) # Enable Login
login_manager.login_view = "login" # Enable redirects if unauthorized
@login_manager.user_loader
def user_loader(id):
return User.get_user(specter, id)
def login(id):
login_user(user_loader(id))
app.login = login
# Attach specter instance so child views (e.g. hwi) can access it
app.specter = specter
@ -71,7 +76,7 @@ def init_app(app, hwibridge=False, specter=None):
if not hwibridge:
with app.app_context():
from cryptoadvance.specter import controller
if app.config.get("TESTING") and len(app.view_functions) <=3 :
if app.config.get("TESTING") and len(app.view_functions) <= 3:
# Need to force a reload as otherwise the import is skipped
# in pytest, the app is created anew for ech test
# But we shouldn't do that if not necessary as this would result in
@ -84,6 +89,7 @@ def init_app(app, hwibridge=False, specter=None):
return redirect('/hwi/settings')
return app
def create_and_init():
''' This method can be used to fill the FLASK_APP-env variable like
export FLASK_APP="src/cryptoadvance/specter/server:create_and_init()"

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB