Skip to content

Commit d65fe23

Browse files
k9ertmoneymanolis
andauthored
Feature: Menu extensions can extend menus (#1648)
* Feature: Menu extensions can extend menus * feature now complete * documentation * rename callback * Adding add_wallettab * docs revised * adding wallet_alias and docs * pictures for the docs * added import * forgotten change * forgotten part 2 * examples in settings and wallet menu * Removing Werkzeug Request logs for Cypress * selectWallet twice to fix weird issues Co-authored-by: moneymanolis <[email protected]>
1 parent e228250 commit d65fe23

File tree

20 files changed

+180
-36
lines changed

20 files changed

+180
-36
lines changed

cypress/integration/spec_balances_amounts.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ describe('Test the rendering of balances and amounts', () => {
7575
cy.get('#satoshis_hot_keys_hot_sign_btn').click()
7676
cy.get('#hot_enter_passphrase__submit').click()
7777
cy.get('#broadcast_local_btn').click()
78-
cy.reload()
78+
cy.selectWallet('Ghost wallet')
79+
// Once again because only once doesn't work for some stupid unknown reason
7980
cy.selectWallet('Ghost wallet')
8081
cy.get('#unconfirmed_amount').should('have.text', '0.05000000')
8182
cy.get('#unconfirmed_amount').find('.thousand-digits-in-btc-amount').children().each((element) => {

cypress/support/commands.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,7 @@ Cypress.Commands.add("deleteWallet", (name) => {
215215

216216
Cypress.Commands.add("selectWallet", (name) => {
217217
cy.get('body').then(($body) => {
218-
if ($body.text().includes(name)) {
219-
cy.contains(name).click( {force: true} )
220-
}
218+
cy.contains(name).click( {force: true} )
221219
})
222220
})
223221

docs/extensions.md

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A developer's guide for the Specter Desktop `Extension` framework.
44

5-
We currently rework the naming of extensions/plugins/services. If not otherwise stated, you can see those three terms as the same, for now.
5+
We currently rework the naming of extensions/plugins/services. If not otherwise stated, for now, they are used interchangeably.
66

77
## TL;DR
88

@@ -104,7 +104,7 @@ All the attributes of an extension are currently (json support is planned) defin
104104

105105
## Extension attributes
106106
Here is an example. This class definition MUST be stored in a file called "service.py" within a package with the name `org-id.specterext.extions-id`.
107-
```
107+
```python
108108
class DiceService(Service):
109109
id = "dice"
110110
name = "Specter Dice"
@@ -117,18 +117,18 @@ class DiceService(Service):
117117
devstatus = devstatus_alpha
118118
```
119119
This defines the base `Service` class (to be renamed to "Extension") that all extensions must inherit from. This also enables `extension` auto-discovery. Any feature that is common to most or all `Service` integrations should be implemented here.
120-
With inheriting from `Service` you get some usefull methods explained later.
120+
With inheriting from `Service` you get some useful methods explained later.
121121

122-
The `id` needs to be unique within a specific specter-instance where this extension is part of. The `name` is the displayname as shown to the user in the plugin-area (currently there is not yet a technical difference between extensions and plugins). The `icon` will be used where labels are used to be diplayed if this extension is reserving addresses. The `logo` and the `desc`ription is also used in the plugin-area ("choose plugins").
122+
The `id` needs to be unique within a specific Specter instance where this extension is part of. The `name` is the display name as shown to the user in the plugin area (currently there is not yet a technical difference between extensions and plugins). The `icon` will be used where labels are used to be diplayed if this extension is reserving addresses. The `logo` and the `desc`ription is also used in the plugin area ("Choose plugins").
123123
If the extension has a UI (currently all of them have one), `has_blueprint` is True. `The blueprint_module` is referencing the controller module where endpoints are defined. It's recommended to follow the format `org.specterext.extions-id.controller`.
124124
`isolated_client` should not be used yet. It is determining where in the url-path tree the blueprint will be mounted. This might have an impact on whether the extension's frontend client has access to the cookie used in Specter. Check `config.py` for details.
125-
`devstatus` is one of `devstatus_alpha`, `devstatus_beta` or `devstatus_prod` defined in `cryptoadvance.specter.services.service`. Each Specter instance will have a config variable called `SERVICES_DEVSTATUS_THRESHOLD` (prod in Production and alpha in Development) and depending on that, the plugin will be available to the user.
125+
`devstatus` is one of `devstatus_alpha`, `devstatus_beta` or `devstatus_prod` defined in `cryptoadvance.specter.services.service`. Each Specter instance will have a config variable called `SERVICES_DEVSTATUS_THRESHOLD` (prod in Production and alpha in Development) and depending on that, the plugin will be available to the user.
126126

127127
## Frontend aspects
128128

129129
As stated, you can have your own frontend with a blueprint. If you only have one, it needs to have a `/` route in order to be linkable from the `choose your plugin` page.
130130
If you create your extension with a blueprint, it'll also create a controller for you which, simplified, looks like this:
131-
```
131+
```python
132132
rubberduck_endpoint = ScratchpadService.blueprint
133133

134134
def ext() -> ScratchpadService:
@@ -150,20 +150,20 @@ def index():
150150
[...]
151151
```
152152
But you can also have more than one blueprint. Define them like this in your service class:
153-
```
153+
```python
154154
blueprint_modules = {
155155
"default" : "mynym.specterext.rubberduck.controller",
156156
"ui" : "mynym.specterext.rubberduck.controller_ui"
157157
}
158158
```
159159
You have to have a default blueprint which has the above mentioned index page.
160160
In your controller, the endpoint needs to be specified like this:
161-
```
161+
```python
162162
ui = RubberduckService.blueprints["ui"]
163163
```
164164

165-
You might have an extension which wants to inject e.g. javascript code into each and every page of specter-desktop. The extension needs to be activated for the user, though. You can do that via overwriting one of the `inject_in_basejinja_*` methods in your service-class:
166-
```
165+
You might have an extension which wants to inject e.g. JavaScript code into each and every page of Specter Desktop. The extension needs to be activated for the user, though. You can do that via overwriting one of the `inject_in_basejinja_*` methods in your service class:
166+
```python
167167
@classmethod
168168
def inject_in_basejinja_head(cls):
169169
''' e.g. rendering some snippet '''
@@ -212,14 +212,14 @@ This is also where `Service`-wide configuration or other information should be s
212212
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()`.
213213

214214
This simplifies code to just asking for:
215-
```
215+
```python
216216
from .service_encrypted_storage import ServiceEncryptedStorageManager
217217

218218
ServiceEncryptedStorageManager.get_instance().get_current_user_service_data(service_id=some_service_id)
219219
```
220220

221221
As a further convenience, the `Service` base class itself encapsulates `Service`-aware access to this per-`User` encrypted storage:
222-
```
222+
```python
223223
@classmethod
224224
def get_current_user_service_data(cls) -> dict:
225225
return ServiceEncryptedStorageManager.get_instance().get_current_user_service_data(service_id=cls.id)
@@ -248,19 +248,98 @@ Unfortunately, the two unencrypted classes are derived from the encrypted one ra
248248

249249
### Service configuration
250250
In order to separate the service-configuration from the main-configuration, you can specify your config in a file called `config.py`. It's structure is similiar to the specter-wide `config.py`, e.g.:
251-
```
251+
```python
252252
class BaseConfig():
253253
SWAN_API_URL="https://dev-api.swanbitcoin.com"
254254

255255
class ProductionConfig(BaseConfig):
256256
SWAN_API_URL="https://api.swanbitcoin.com"
257257
```
258258
In your code, you can access the correct value as in any other flask-code, like `api_url = app.config.get("SWAN_API_URL")`. If the instance is running a config (e.g. `DevelopmentConfig`) which is not available in your service-specific config (as above), the inheritance-hirarchy from the mainconfig will get traversed and the first hit will get get configured. In this example, it would be `BaseConfig`.
259+
259260
### Callback methods
260-
Your service class will inherit a callback-method which will get called for various reasons with the "reason" being a string as the first parameter. Checkout the `cryptoadvance.specter.services.callbacks` file for the specific callbacks.
261+
Your service class will inherit a callback method which will get called for various reasons with the "reason" being a string as the first parameter. Checkout the `cryptoadvance.specter.services.callbacks` file for the specific callbacks.
261262

262-
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).
263+
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).
263264

264265
### `controller.py`
265-
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.
266+
The minimal url routes for `Service` selection and management. As usual 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.
267+
268+
### Extending the settings dialog
269+
You can extend the settings dialog with your own templates. To do that, create a callback method in your service like:
270+
```python
271+
from cryptoadvance.specter.services import callbacks
272+
# [...]
273+
def callback_add_settingstabs(self):
274+
''' Extending the settings tab with an own tab called "myexttitle" '''
275+
return [{"title": "myexttitle", "endpoint":"myext_something"}]
276+
277+
def callback_add_wallettabs(self):
278+
''' Extending the wallets tab with an own tab called "mywalletdetails" '''
279+
return [{"title": "mywalletdetails", "endpoint":"myext_mywalletdetails"}]
280+
```
281+
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:
282+
283+
```python
284+
@myext_endpoint.route("/settings_something", methods=["GET"])
285+
def myext_something():
286+
return render_template(
287+
"myext/some_settingspage.jinja",
288+
ext_settingstabs = app.specter.service_manager.execute_ext_callbacks(
289+
callbacks.add_settingstabs
290+
)
291+
)
292+
```
293+
294+
If you want to have an additional wallet tab, you would specify something like:
295+
296+
```python
297+
@myext_endpoint.route("/wallet/<wallet_alias>/mywalletdetails", methods=["GET"])
298+
def myext_mywalletdetails(wallet_alias):
299+
wallet: Wallet = app.specter.wallet_manager.get_by_alias(wallet_alias)
300+
return render_template(
301+
"myext/mywalletdetails.jinja",
302+
wallet_alias=wallet_alias,
303+
wallet=wallet,
304+
specter=app.specter,
305+
ext_wallettabs = app.specter.service_manager.execute_ext_callbacks(
306+
callbacks.add_wallettabs
307+
)
308+
)
309+
```
310+
311+
The `some_settingspage.jinja` should probably look exactly like all the other setting pages and you would do this like this:
312+
313+
```jinja
314+
{% extends "base.jinja" %}
315+
{% block main %}
316+
<form action="?" method="POST" onsubmit="showPacman()">
317+
<input type="hidden" class="csrf-token" name="csrf_token" value="{{ csrf_token() }}"/>
318+
<h1 id="title" class="settings-title">Settings</h1>
319+
{% from 'settings/components/settings_menu.jinja' import settings_menu %}
320+
{{ settings_menu('myext_something', current_user, setting_exts) }}
321+
<div class="card" style="margin: 20px auto;">
322+
<h1>{{ _("Something something") }} </h1>
323+
</div>
324+
</form>
325+
{% endblock %}
326+
```
327+
328+
![](./images/extensions/add_settingstabs.png)
329+
330+
331+
A reasonable `mywalletdetails.jinja` would look like this:
332+
333+
```jinja
334+
{% extends "wallet/components/wallet_tab.jinja" %}
335+
{% set tab = 'my details' %}
336+
{% block content %}
337+
<br>
338+
<div class="center card" style="width: 610px; padding-top: 25px;">
339+
Some content here for the wallet {{ wallet_alias }}
340+
</div>
341+
{% endblock %}
342+
```
343+
344+
![](./images/extensions/add_wallettabs.png)
266345

11.3 KB
Loading
21.4 KB
Loading

src/cryptoadvance/specter/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ class BaseConfig(object):
7070
"SPECTER_LOGFORMAT", "[%(asctime)s] %(levelname)s in %(module)s: %(message)s"
7171
)
7272

73+
# The Werkzeug Logs which are documenting each request are quite annoying with Cypress
74+
# but by default, it's ok
75+
ENABLE_WERZEUG_REQUEST_LOGGING = True
76+
7377
# CERT and KEY is for running self-signed-ssl-certs. Check cli_server for details
7478
CERT = os.getenv("CERT", None)
7579
KEY = os.getenv("KEY", None)
@@ -239,6 +243,9 @@ class CypressTestConfig(TestConfig):
239243
)
240244
PORT = os.getenv("PORT", 25444)
241245

246+
# The Werkzeug Logs which are documenting each request are quite annoying with Cypress
247+
ENABLE_WERZEUG_REQUEST_LOGGING = False
248+
242249
# need to be static in order to (un-)tar bitcoind-dirs reliable
243250
DEFAULT_SPECTER_CONFIG = {"uid": "123456"}
244251

src/cryptoadvance/specter/managers/service_manager.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,11 +264,18 @@ def execute_ext_callbacks(self, callback_id, *args, **kwargs):
264264
raise Exception(f"Non existing callback_id: {callback_id}")
265265
# No debug statement here possible as this is called for every request and would flood the logs
266266
# logger.debug(f"Executing callback {callback_id}")
267+
return_values = {}
267268
for ext in self.services.values():
268269
if hasattr(ext, f"callback_{callback_id}"):
269-
getattr(ext, f"callback_{callback_id}")(*args, **kwargs)
270+
return_values[ext.id] = getattr(ext, f"callback_{callback_id}")(
271+
*args, **kwargs
272+
)
270273
elif hasattr(ext, "callback"):
271-
ext.callback(callback_id, *args, **kwargs)
274+
return_values[ext.id] = ext.callback(callback_id, *args, **kwargs)
275+
# Filtering out all None return values
276+
return_values = {k: v for k, v in return_values.items() if v is not None}
277+
# logger.debug(f"return_values for callback {callback_id} {return_values}")
278+
return return_values
272279

273280
@property
274281
def services(self) -> Dict[str, Service]:

src/cryptoadvance/specter/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def create_app(config=None):
102102
logger.info(f"SPECTER_DATA_FOLDER: {app.config['SPECTER_DATA_FOLDER']}")
103103
# Might be convenient to know later where it came from (see Service configuration)
104104
app.config["SPECTER_CONFIGURATION_CLASS_FULLNAME"] = config_name
105+
if not app.config.get("ENABLE_WERZEUG_REQUEST_LOGGING"):
106+
logging.getLogger("werkzeug").disabled = True
105107
app.wsgi_app = ProxyFix(
106108
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
107109
)

src/cryptoadvance/specter/server_endpoints/settings.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@
2828
)
2929
from ..persistence import write_devices, write_wallet
3030
from ..server_endpoints import flash
31-
from ..services.service import Service
32-
from ..specter_error import ExtProcTimeoutException, handle_exception
33-
from ..user import User, UserSecretException
31+
from ..services.service import callbacks
32+
from ..specter_error import handle_exception
33+
from ..user import UserSecretException
3434
from ..util.sha256sum import sha256sum
3535
from ..util.shell import get_last_lines_from_file
3636
from ..util.tor import start_hidden_service, stop_hidden_services
@@ -43,6 +43,17 @@
4343
settings_endpoint = Blueprint("settings_endpoint", __name__)
4444

4545

46+
@settings_endpoint.context_processor
47+
def inject_common_stuff():
48+
"""Can be used in all jinja2 templates of this Blueprint
49+
Injects the additional settings_tabs via extentions
50+
"""
51+
ext_settingstabs = app.specter.service_manager.execute_ext_callbacks(
52+
callbacks.add_settingstabs
53+
)
54+
return dict(ext_settingstabs=ext_settingstabs)
55+
56+
4657
@settings_endpoint.route("/", methods=["GET"])
4758
@login_required
4859
def settings():

src/cryptoadvance/specter/server_endpoints/wallets.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ..managers.wallet_manager import purposes
1717
from ..persistence import delete_file
1818
from ..server_endpoints import flash
19+
from ..services import callbacks
1920
from ..specter_error import SpecterError, handle_exception
2021
from ..util.tx import convert_rawtransaction_to_psbt, is_hex
2122
from ..util.wallet_importer import WalletImporter
@@ -49,6 +50,17 @@ def wrapper(*args, **kwargs):
4950
return wrapper
5051

5152

53+
@wallets_endpoint.context_processor
54+
def inject_common_stuff():
55+
"""Can be used in all jinja2 templates of this Blueprint
56+
Injects the additional wallet_tabs via extentions
57+
"""
58+
ext_wallettabs = app.specter.service_manager.execute_ext_callbacks(
59+
callbacks.add_wallettabs
60+
)
61+
return dict(ext_wallettabs=ext_wallettabs)
62+
63+
5264
################## Wallet overview #######################
5365

5466

0 commit comments

Comments
 (0)