core+gui+cli: animated QR will transfer (balqr/UR1/UR2/BBQR codecs, audio channel, export/import wizard)

This commit is contained in:
2026-09-09 08:43:32 -04:00
parent f45d3be321
commit b3624fef1c
30 changed files with 4037 additions and 624 deletions

View File

@@ -16,11 +16,10 @@ the Qt button and the OS/subprocess glue.
import os
import subprocess
from electrum.gui.qt.util import getSaveFileName
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QInputDialog, QMenu, QToolButton
from electrum.gui.qt.util import getSaveFileName
from ...core.reminders import write_temp_ics
from .common import _, _logger

View File

@@ -15,7 +15,6 @@ hosts a few GUI helpers that do not deserve a module of their own:
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
"""
import copy
import enum
import os
import subprocess
@@ -82,6 +81,7 @@ from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDateTimeEdit,
@@ -94,6 +94,7 @@ from PyQt6.QtWidgets import (
QMenu,
QMenuBar,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QSpinBox,
@@ -121,7 +122,7 @@ from ...core.heirs import (
# --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.util import Util
from ...core.util import Util, copy_structure
from ...core.will import (
AmountException,
HeirChangeException,

File diff suppressed because it is too large Load Diff

View File

@@ -20,15 +20,13 @@ from PyQt6.QtWidgets import QLineEdit as _QLineEdit
from PyQt6.QtWidgets import QMessageBox, QStyledItemDelegate
from .common import (
_,
_logger,
OP_RETURN_PREFIX,
BalTimestamp,
Buttons,
CancelButton,
HelpButton,
MessageBoxMixin,
MyTreeView,
OP_RETURN_PREFIX,
OkButton,
QAbstractItemView,
QApplication,
@@ -46,15 +44,17 @@ from .common import (
QSpinBox,
QStandardItem,
QStandardItemModel,
Qt,
QToolButton,
QVBoxLayout,
QWidget,
Qt,
TaskThread,
Util,
Will,
WillItem,
Willexecutors,
WillItem,
_,
_logger,
char_width_in_lineedit,
datetime,
enum,
@@ -63,8 +63,8 @@ from .common import (
import_meta_gui,
is_op_return_address,
partial,
read_QIcon_from_bytes,
read_json_file,
read_QIcon_from_bytes,
server_status_text,
server_status_tooltip,
signature_suffix,
@@ -663,13 +663,11 @@ class PreviewList(MyTreeView, MessageBoxMixin):
menu.addAction(_("Prepare"), self.build_transactions)
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
export_menu = menu.addMenu(_("Export"))
export_menu.addAction(_("All"), self.export_will)
export_menu.addAction(_("Valid"), self.export_will_valid)
export_menu.addAction(_("Valid NC"), self.export_will_valid_incomplete)
export_menu.addAction(_("QR Codes"), self.export_will_via_qr)
menu.addAction(_("Import"), self.import_will_into_details)
menu.addAction(_("Import via QR"), self.import_will_via_qr)
# Export/Import open a single window that offers all transports
# (file / QR / audio). The Choose Filter / transport settings live
# inside that window.
menu.addAction(_("Export"), self.export_will)
menu.addAction(_("Import"), self.import_will)
menu.addAction(_("Merge"), self.merge_will)
menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check)
@@ -735,48 +733,15 @@ class PreviewList(MyTreeView, MessageBoxMixin):
if will:
self.update_will(will)
def export_json_file(self, path):
write_json_file(path, self.will)
def export_will(self):
self.bal_window.export_will()
self.update()
self.bal_window.export_will_dialog()
def export_will_valid(self):
"""Export only the will items that are valid."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID")
}
if not subset:
self.show_message(_("No valid will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def export_will_valid_incomplete(self):
"""Export only the will items that are valid but not yet fully signed (V-NC)."""
subset = {
wid: wi
for wid, wi in self.will.items()
if wi.get_status("VALID") and not wi.get_status("COMPLETE")
}
if not subset:
self.show_message(_("No valid, incomplete will item to export"))
return
self.bal_window.export_will(will=subset)
self.update()
def import_will(self):
self.bal_window.import_will_dialog()
def import_will_into_details(self):
self.bal_window.import_will_into_details()
def export_will_via_qr(self):
self.bal_window.export_will_via_qr()
def import_will_via_qr(self):
self.bal_window.import_will_via_qr()
def merge_will(self):
self.bal_window.merge_will_ui()

View File

@@ -20,10 +20,7 @@ from electrum.util import EventListener, event_listener
from PyQt6.QtWidgets import QLayout
from ...core.qrtransfer import CHUNK_PRESETS, preset_index_for_chunk_size
from .common import (
_,
_logger,
BalPlugin,
Buttons,
EnterButton,
@@ -40,6 +37,8 @@ from .common import (
QWidget,
UserCancelled,
Willexecutors,
_,
_logger,
add_widget,
partial,
read_QIcon_from_bytes,

View File

@@ -29,17 +29,15 @@ from ...core.input_rules import (
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton
from .common import (
_,
_logger,
Any,
BTCAmountEdit,
BalTimestamp,
ColorScheme,
DECIMAL_POINT,
Decimal,
HelpButton,
NLOCKTIME_BLOCKHEIGHT_MAX,
NLOCKTIME_MAX,
Any,
BalTimestamp,
BTCAmountEdit,
ColorScheme,
Decimal,
HelpButton,
Optional,
QAbstractSpinBox,
QCheckBox,
@@ -57,13 +55,15 @@ from .common import (
QSpinBox,
QStyle,
QStyleOptionFrame,
Qt,
QTextEdit,
QVBoxLayout,
QWidget,
Qt,
Union,
Util,
Will,
_,
_logger,
char_width_in_lineedit,
datetime,
getSaveFileName,

View File

@@ -25,8 +25,7 @@ from ...core.checkalive import (
resolve_date_to_check,
)
from .common import (
_,
_logger,
OP_RETURN_PREFIX,
AmountException,
BalPlugin,
Buttons,
@@ -40,10 +39,10 @@ from .common import (
Mapping,
Network,
NoHeirsException,
NoWillExecutorNotPresent,
NotCompleteWillException,
OP_RETURN_PREFIX,
NoWillExecutorNotPresent,
OkButton,
Optional,
PaymentIdentifier,
QGridLayout,
QLabel,
@@ -57,15 +56,17 @@ from .common import (
TxFeesChangedException,
Util,
Will,
WillexecutorChangeException,
WillExecutorFeeTooHighException,
WillExecutorNotPresent,
Willexecutors,
WillExpiredException,
WillItem,
WillPostponedException,
WillexecutorChangeException,
Willexecutors,
_,
_logger,
char_width_in_lineedit,
copy,
copy_structure,
export_meta_gui,
import_meta_gui,
is_onion_url,
@@ -73,8 +74,8 @@ from .common import (
is_tor_active,
log_error,
partial,
read_QIcon_from_bytes,
read_json_file,
read_QIcon_from_bytes,
show_on_top,
shown_cv,
time,
@@ -88,8 +89,10 @@ from .dialogs import (
BalWizardDialog,
WillDetailDialog,
WillExecutorDialog,
WillQrExportDialog,
WillQrImportDialog,
WillExportDialog,
WillImportDialog,
_complete_import,
decode_will_payload,
)
from .lists import HeirListWidget, PreviewList
from .widgets import LockTimeWidget, PercAmountEdit
@@ -517,11 +520,11 @@ class BalWindow:
tx["my_locktime"] = txs[txid].my_locktime
tx["heirsvalue"] = txs[txid].heirsvalue
tx["description"] = txs[txid].description
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
tx["willexecutor"] = copy_structure(txs[txid].willexecutor)
tx["status"] = _("New")
tx["baltx_fees"] = txs[txid].tx_fees
tx["time"] = creation_time
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
tx["heirs"] = copy_structure(txs[txid].heirs)
tx["txchildren"] = []
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
self.update_will(will)
@@ -1638,6 +1641,19 @@ class BalWindow:
else:
write_json_file(path, {wid: wi.to_dict() for wid, wi in will.items()})
def export_tx_file(self, path, will=None):
"""Export only the serialized transactions of the given will items.
Writes a plain text file with every transaction (or PSBT) serialized
on a single line, separated by a comma (``tx1,tx2,tx3``). The raw hex
and PSBT base64 alphabets never contain a comma, so the separator is
unambiguous. When ``will`` is omitted the live will items are used.
"""
willitems = will if will is not None else self.willitems
serialized = ",".join(str(wi.tx) for wid, wi in willitems.items())
with open(path, "w", encoding="utf-8") as f:
f.write(serialized)
def export_will(self, will=None):
try:
export_meta_gui(
@@ -1647,18 +1663,23 @@ class BalWindow:
self.show_error(str(e))
raise e
def export_will_via_qr(self, will=None):
"""Export the will (default: the live one) as QR codes on screen.
def export_will_dialog(self, will=None, initial_mode: Optional[str] = None):
"""Open the unified export window (File / QR / Audio).
The selected transactions are serialized with the wire format of
:mod:`bal.core.qrtransfer` and shown, one frame at a time, in a
:class:`WillQrExportDialog`. When Electrum's ``audio_modem`` plugin
is available (:meth:`get_audio_modem_plugin`) the dialog also offers
an "Audio" send button.
The window lets the user pick an All / Valid / Valid NC filter in
the top row and choose one of the three transports, each with its
contextual settings (file format for File, QR-code size and autoplay
for QR, KB/sec for Audio). ``will`` defaults to the live will items;
``initial_mode`` opens the window directly on the given transport.
"""
try:
willitems = will if will is not None else self.willitems
d = WillQrExportDialog(self, will=willitems, bal_plugin=self.bal_plugin)
d = WillExportDialog(
self,
will=willitems,
bal_plugin=self.bal_plugin,
initial_mode=initial_mode or "file",
)
show_on_top(d)
except Exception as e:
self.show_error(str(e))
@@ -1693,6 +1714,22 @@ class BalWindow:
return
plugin._send(parent=self.window, blob=payload)
def set_audio_modem_bitrate(self, kbps):
"""Set the ``audio_modem`` plugin transfer speed to ``kbps`` KB/sec.
Both the send and the receive paths read ``modem_config``, so the
sender and the receiver must be configured with the same speed. Raises
when the plugin (or its ``amodem`` dependency) is unavailable.
"""
plugin = self.get_audio_modem_plugin()
if plugin is None:
raise Exception(_("Audio MODEM plugin is not available."))
try:
import amodem.config
except Exception as e:
raise Exception(str(e)) from e
plugin.modem_config = amodem.config.bitrates[int(kbps)]
def merge_will(self, imported):
"""Merge imported will items into the live will.
@@ -1816,16 +1853,34 @@ class BalWindow:
def on_file(path):
try:
willitems = self._load_will_file(path)
with open(path, "r", encoding="utf-8") as f:
text = f.read()
except Exception as e:
self.show_error(_("Invalid will file: {}").format(e))
return
# Attach wallet/input info so the imported txs can be signed and
# broadcast (mirrors what merge_will_from_file does).
Will.normalize_will(willitems, self.wallet)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
imported.update(willitems)
kind, data = decode_will_payload(text)
try:
if kind == "will":
willitems = self._load_will_payload(data)
# Attach wallet/input info so the imported txs can be
# signed and broadcast (mirrors merge_will_from_file).
Will.normalize_will(willitems, self.wallet)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
imported.update(willitems)
else:
# Serialized transactions: route through the shared import
# tail (validity pass + review/sign wizard).
_complete_import(
self,
self.bal_plugin,
text,
show_error=self.show_error,
show_warning=self.show_warning,
close=lambda: None,
)
except Exception as e:
self.show_error(_("Invalid will file: {}").format(e))
def on_success():
if not imported:
@@ -1835,16 +1890,16 @@ class BalWindow:
import_meta_gui(self.window, _("will"), on_file, on_success)
def import_will_via_qr(self):
"""Import a will through QR codes (or audio) and review/sign it.
def import_will_dialog(self):
"""Open the unified import window (File / QR / Audio).
Opens a :class:`WillQrImportDialog`. The captured transactions are
parsed into fresh :class:`WillItem` objects (never touching the
live will), run through the same local validity pass the merge flow
uses, and are then presented in the per-transaction review wizard
(:class:`WillTxReviewSignDialog`).
The window offers three transports: File opens the read-only
:class:`WillDetailDialog` preview; QR and Audio capture the
transfer and send it through the per-transaction review wizard
(:class:`WillTxReviewSignDialog`). Every flow works on fresh
:class:`WillItem` objects and never touches the live will.
"""
d = WillQrImportDialog(self, bal_plugin=self.bal_plugin)
d = WillImportDialog(self, bal_plugin=self.bal_plugin)
show_on_top(d)
def _load_will_file(self, path):
@@ -1855,6 +1910,15 @@ class BalWindow:
willitems[k] = WillItem(data[k], _id=k)
return willitems
def _load_will_payload(self, data):
"""Build WillItems from decoded whole-will JSON data."""
willitems = {}
for k, v in data.items():
d = dict(v)
d["tx"] = tx_from_any(d["tx"])
willitems[k] = WillItem(d, _id=k)
return willitems
def check_transactions_task(self, will):
start = time.time()
# Servers are now contacted in parallel (see