Skip to content

Commit 3050893

Browse files
author
k9ert
authored
Feature: Extensions can add new devices (#1887)
1 parent 7d78af8 commit 3050893

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1016
-382
lines changed

docs/extensions.md

Lines changed: 0 additions & 345 deletions
This file was deleted.

docs/extensions/callbacks.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
2+
# Callback methods
3+
4+
Callback methods will get called from within Specter Desktop Core for various reasons. You can implement them in your service-class in order to react on those occasions.
5+
6+
Checkout the `cryptoadvance.specter.services.callbacks` file for all the specific callbacks.
7+
8+
Some important one is the `after_serverpy_init_app` which passes a `Scheduler` class which can be used to setup regular tasks. A list of currently implemented callback-methods along with their descriptions are available in [`/src/cryptoadvance/specter/services/callbacks.py`](https://github.com/cryptoadvance/specter-desktop/blob/master/src/cryptoadvance/specter/services/callbacks.py).
9+
10+
11+

docs/extensions/data-storage.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Data storage
2+
As an extension developer, you have the choice to completely manage your own persistence. Don't hesitate to completely do your own thing. However, if your requirements are not that complicated, you can rely/use one of two options: You either have data which need encryption (via the passwords of the users) or you don't have that requirement.
3+
4+
## ServiceEncryptedStorage
5+
Some `Services` will require user secrets (e.g. API key and secret). Each Specter `User` will have their own on-disk encrypted `ServiceEncryptedStorage` with filename `<username>_services.json`. Note that the user's secrets for all `Services` will be stored in this one file.
6+
7+
This is built upon the `GenericDataManager` class which supports optional encrypted fields. In this case all fields are encrypted. The `GenericDataManager` encryption can only be unlocked by each `User`'s individual `user_secret` that itself is stored encrypted on-disk; it is decrypted to memory when the `User` logs in.
8+
9+
For this reason `Services` cannot be activated unless the user is signing in with a password-protected account (the default no-password `admin` account will not work).
10+
11+
_Note: During development if the Flask server is restarted or auto-reloads, the user's decrypted `user_secret` will no longer be in memory. The Flask context will still consider the user logged in after restart, but code that relies on having access to the `ServiceEncryptedStorage` will throw an error and/or prompt the user to log in again._
12+
13+
It is up to each `Service` implementation to decide what data is stored; the `ServiceEncryptedStorage` simply takes arbitrary json in and delivers it back out.
14+
15+
This is also where `Service`-wide configuration or other information should be stored, _**even if it is not secret**_ (see above intro about not polluting other existing data stores).
16+
17+
## ServiceEncryptedStorageManager
18+
Because the `ServiceEncryptedStorage` is specific to each individual user, this manager provides convenient access to automatically retrieve the `current_user` from the Flask context and provide the correct user's `ServiceEncryptedStorage`. It is implemented as a `Singleton` which can be retrieved simply by importing the class and calling `get_instance()`.
19+
20+
This simplifies code to just asking for:
21+
```python
22+
from .service_encrypted_storage import ServiceEncryptedStorageManager
23+
24+
ServiceEncryptedStorageManager.get_instance().get_current_user_service_data(service_id=some_service_id)
25+
```
26+
27+
As a further convenience, the `Service` base class itself encapsulates `Service`-aware access to this per-`User` encrypted storage:
28+
```python
29+
@classmethod
30+
def get_current_user_service_data(cls) -> dict:
31+
return ServiceEncryptedStorageManager.get_instance().get_current_user_service_data(service_id=cls.id)
32+
```
33+
34+
Whenever possible, external code should not directly access these `Service`-related support classes but rather should ask for them through the `Service` class.
35+
36+
## `ServiceUnencryptedStorage`
37+
A disadvantage of the `ServiceEncryptedStorage` is, that the user needs to be freshly logged in in order to be able to decrypt the secrets. If you want to avoid that login but your extension should still store data on disk, you can use the `ServiceUnencryptedStorage`.
38+
39+
In parallel with the `ServiceEncryptedStorageManager` there is also a `ServiceUnencryptedStorageManager` which is used exactly the same way.
40+
41+
## `ServiceAnnotationsStorage`
42+
Annotations are any address specific or transaction specific data from a `Service` that we might want to present to the user (not yet implemented). Example: a `Service` that integrates with a onchain store would have product/order data associated with a utxo. That additional data could be imported by the `Service` and stored as an annotation. This annotation data could then be displayed to the user when viewing the details for that particular address or tx.
43+
44+
Annotations are stored on a per-wallet and per-`Service` basis as _unencrypted_ on-disk data (filename: `<wallet_alias>_<service>.json`).
45+
46+
_Note: current `Service` implementations have not yet needed this feature so displaying annotations is not yet implemented._
47+
48+
## Data Storage Class Diagram
49+
50+
Unfortunately, the two unencrypted classes are derived from the encrypted one rather than having it the other way around or having abstract classes. This makes the diagram maybe a bit confusing.
51+
52+
[![](https://mermaid.ink/img/pako:eNqVVMFuwjAM_ZUqJzaVw66IIU0D7bRd0G6VItO4LFvqoCRlqhj_PpeWASLduhyiqH7v-dlOsxO5VSgmIjfg_VzD2kGZUcKr3Z-Q0Ol8DgGegWCNLpl-jcfJEt1W57ig3NWbgGoZrONoS-oJXjBfCaPcPxI-ENkAQVvyF7RHS4VeVw5WBpea1gaDpZbZZ6eT_9VyzMK18_8oZeIuE8l4fMsn4tOQRvZm7FPra24XZgLjK4--38BFTe1-uCORAe3acLOk1KSDlCOPpkgTxSBZWKPQpUlniUcnP7C-f7GENycmQYnSFvLdc7zQBk-hn1r4OxrlTxFjQY3ORKSHbUfcn3vuqTFm_MIyt4h3pX1zraTCA_0sX0b9ya5varxPB7DUKk0-wfC1HSh_PeJdFB7_Mc6sTKf--Hk2G96769lHhJrlW7xc1bJp538qGpQjJnVqhUhFia4ErfiROwhlIrxhiZmY8FFhAZUJmciogVYbHj8ulOb8YlKA8ZgKqIJd1pSLSXAVHkHdW9mh9t9YNMxZ)](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqVVMFuwjAM_ZUqJzaVw66IIU0D7bRd0G6VItO4LFvqoCRlqhj_PpeWASLduhyiqH7v-dlOsxO5VSgmIjfg_VzD2kGZUcKr3Z-Q0Ol8DgGegWCNLpl-jcfJEt1W57ig3NWbgGoZrONoS-oJXjBfCaPcPxI-ENkAQVvyF7RHS4VeVw5WBpea1gaDpZbZZ6eT_9VyzMK18_8oZeIuE8l4fMsn4tOQRvZm7FPra24XZgLjK4--38BFTe1-uCORAe3acLOk1KSDlCOPpkgTxSBZWKPQpUlniUcnP7C-f7GENycmQYnSFvLdc7zQBk-hn1r4OxrlTxFjQY3ORKSHbUfcn3vuqTFm_MIyt4h3pX1zraTCA_0sX0b9ya5varxPB7DUKk0-wfC1HSh_PeJdFB7_Mc6sTKf--Hk2G96769lHhJrlW7xc1bJp538qGpQjJnVqhUhFia4ErfiROwhlIrxhiZmY8FFhAZUJmciogVYbHj8ulOb8YlKA8ZgKqIJd1pSLSXAVHkHdW9mh9t9YNMxZ)
53+
54+
## Implementation Notes
55+
Efforts has been taken to provide `Service` data storage that is separate from existing data stores in order to keep those areas clean and simple. Where touchpoints are unavoidable, they are kept to the absolute bare minimum (e.g. `User.services` list in `users.json`, `Address.service_id` field).

docs/extensions/devices.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Adding Devices
2+
3+
Devices are the main building blocks for singlesig and multisig wallets. Different Hardwarewallets are represented as devices as well as the Bitcoin Core Hotwallet or the Electrum Wallet which might hold private keys and export xpubs into Specter Desktop.
4+
5+
To Create your own Device, you have to specify the modules containing subclasses of `Device` in `service.py`:
6+
7+
```
8+
class DiceService(Service):
9+
# [...]
10+
devices = ["mynym.specterext.myextensionid.devices.mydevice"]
11+
```
12+
13+
You don't have to place the device in that devices-subdirectory but that's recommended. Here is an example with some explanations:
14+
15+
```python
16+
# [...]
17+
from cryptoadvance.specter.device import Device
18+
19+
class MyDevice(Device):
20+
# the device_type is a string representation of this class which will be used in the
21+
# json-file of a device of that type. Simply use the class-name lowercase
22+
# and make sure it's unique
23+
device_type = "mydevice"
24+
# Will be shown when adding a new device and as a searchterm
25+
name = "Electrum"
26+
# The Icon. Use a b/w.svg
27+
icon = "electrum/img/devices/electrum_icon.svg"
28+
# optional, You might want to have a more specific template for creating a new device
29+
template = "electrum/device/new_device_keys_electrum.jinja"
30+
31+
# If your device is a classic Hardwarewallets, it might have one of these features:
32+
sd_card_support = True
33+
qr_code_support = True
34+
35+
# auto, off or on
36+
# seedsigner uses on. By default it's auto.
37+
qr_code_animate = "off"
38+
39+
```
40+
41+
For sure there might be various methods to overwrite. Please have a look into the `Device` class.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Frontend aspects
2+
3+
## controller.py
4+
5+
You can have your own frontend with a blueprint (flask blueprints are explained [here](https://realpython.com/flask-blueprint/)). If you only have one, it needs to have a `/` route in order to be linkable from the `choose your plugin` page.
6+
If you create your extension with a blueprint, it'll also create a controller for you which, simplified, looks like this:
7+
```python
8+
rubberduck_endpoint = ScratchpadService.blueprint
9+
10+
def ext() -> ScratchpadService:
11+
''' convenience for getting the extension-object'''
12+
return app.specter.ext["rubberduck"]
13+
14+
def specter() -> Specter:
15+
''' convenience for getting the specter-object'''
16+
return app.specter
17+
18+
19+
@rubberduck.route("/")
20+
@login_required
21+
@user_secret_decrypted_required
22+
def index():
23+
return render_template(
24+
"rubberduck/index.jinja",
25+
)
26+
[...]
27+
```
28+
29+
You can also have more than one blueprint. Define them like this in your service class:
30+
```python
31+
blueprint_modules = {
32+
"default" : "mynym.specterext.rubberduck.controller",
33+
"ui" : "mynym.specterext.rubberduck.controller_ui"
34+
}
35+
```
36+
37+
You have to have a default blueprint which has the above mentioned index page.
38+
In your controller, the endpoint needs to be specified like this:
39+
40+
```python
41+
ui = RubberduckService.blueprints["ui"]
42+
```
43+
44+
## Templates and Static Resources
45+
46+
The minimal url routes for `Service` selection and management. As usualy in Flask, `templates` and `static` resources are in their respective subfolders. Please note that there is an additional directory with the id of the extension which looks redundant at first. This is due to the way blueprints are loading templates and ensures that there are no naming collisions. Maybe at a later stage, this can be used to let plugins override other plugin's templates.
47+
48+
## Modifying non-extension pages
49+
50+
You might have an extension which wants to inject e.g. JavaScript code into each and every page of Specter Desktop. You can do that via overwriting one of the `inject_in_basejinja_*` methods in your service-class:
51+
```python
52+
@classmethod
53+
def inject_in_basejinja_head(cls):
54+
''' e.g. rendering some snippet '''
55+
return render_template("devhelp/html_inject_in_basejinja_head.jinja")
56+
57+
@classmethod
58+
def inject_in_basejinja_body_top(cls):
59+
''' or directly returning text '''
60+
return "<script>console.log('Hello from body top')"
61+
62+
@classmethod
63+
def inject_in_basejinja_body_bottom(cls):
64+
return "something here"
65+
```
66+
67+
For this to work, the extension needs to be activated for the user, though.
68+
69+
### Extending dialogs
70+
You can extend the settings dialog or the wallet-dialog with your own templates. To do that, create a callback method in your service like:
71+
72+
```python
73+
from cryptoadvance.specter.services import callbacks
74+
# [...]
75+
def callback_add_settingstabs(self):
76+
''' Extending the settings tab with an own tab called "myexttitle" '''
77+
return [{"title": "myexttitle", "endpoint":"myext_something"}]
78+
79+
def callback_add_wallettabs(self):
80+
''' Extending the wallets tab with an own tab called "mywalletdetails" '''
81+
return [{"title": "mywalletdetails", "endpoint":"myext_mywalletdetails"}]
82+
```
83+
84+
In this case, this would add a tab called "myexttitle" and you're now supposed to provide an endpoint in your controller which might be called `myext_something` e.g. like this:
85+
86+
```python
87+
@myext_endpoint.route("/settings_something", methods=["GET"])
88+
def myext_something():
89+
return render_template(
90+
"myext/some_settingspage.jinja",
91+
ext_settingstabs = app.specter.service_manager.execute_ext_callbacks(
92+
callbacks.add_settingstabs
93+
)
94+
)
95+
```
96+
97+
If you want to have an additional wallet tab, you would specify something like:
98+
99+
```python
100+
@myext_endpoint.route("/wallet/<wallet_alias>/mywalletdetails", methods=["GET"])
101+
def myext_mywalletdetails(wallet_alias):
102+
wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
103+
return render_template(
104+
"myext/mywalletdetails.jinja",
105+
wallet_alias=wallet_alias,
106+
wallet=wallet,
107+
specter=app.specter,
108+
ext_wallettabs = app.specter.service_manager.execute_ext_callbacks(
109+
callbacks.add_wallettabs
110+
)
111+
)
112+
```
113+
114+
The `some_settingspage.jinja` should probably look exactly like all the other setting pages and you would do this like this:
115+
116+
```jinja
117+
{% extends "base.jinja" %}
118+
{% block main %}
119+
<form action="?" method="POST" onsubmit="showPacman()">
120+
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
121+
<h1 id="title" class="settings-title">Settings</h1>
122+
{% from 'settings/components/settings_menu.jinja' import settings_menu %}
123+
{{ settings_menu('myext_something', current_user, setting_exts) }}
124+
<div class="card" style="margin: 20px auto;">
125+
<h1>{{ _("Something something") }} </h1>
126+
</div>
127+
</form>
128+
{% endblock %}
129+
```
130+
131+
![](./images/extensions/add_settingstabs.png)
132+
133+
134+
A reasonable `mywalletdetails.jinja` would look like this:
135+
136+
```jinja
137+
{% extends "wallet/components/wallet_tab.jinja" %}
138+
{% set tab = 'my details' %}
139+
{% block content %}
140+
<br>
141+
<div class="center card" style="width: 610px; padding-top: 25px;">
142+
Some content here for the wallet {{ wallet_alias }}
143+
</div>
144+
{% endblock %}
145+
```
146+
147+
![](./images/extensions/add_wallettabs.png)
148+

0 commit comments

Comments
 (0)