core+gui: chunked multi-QR will transfer (export/import + review/sign wizard, audio channel)
BALQR-framed chunk scheduler (bal/core/qrtransfer.py) with zlib compression, four chunk presets and a QR_CHUNK_SIZE setting (row 16). Export/import dialogs (WillQrExportDialog/WillQrImportDialog), per-tx review-and-sign wizard, export filters (All/Valid/Valid-NC) and an Auto slideshow with speed + loop for the QR codes; optional audio_modem channel with a local receive mirror. _prepare_and_sign_tx refactor (no behaviour change); WillItem.status defaults to '' instead of None (import crash fix); invalidate_will guard for a missing date_to_check. Docs: PLAN_QR_TRANSFER, QML_PLAN, README, HANDOFF, CHANGELOG entry 56, AUDIO_MODEM_DEBIAN.
This commit is contained in:
@@ -11,16 +11,35 @@ All modal/non-modal dialogs of the plugin.
|
||||
* BalBuildWillDialog - the central build/sign/push/broadcast flow.
|
||||
* WillDetailDialog - shows the full will tree for one wallet.
|
||||
* WillExecutorDialog - manage the list of will-executor servers.
|
||||
* WillQrExportDialog - export the will as a sequence of QR codes.
|
||||
* WillQrImportDialog - capture/assemble a will from QR shots (or
|
||||
audio) and send it to the review+sign wizard.
|
||||
* WillTxReviewSignDialog - per-transaction review/sign wizard for the
|
||||
imported will (external copy, never touches the live will).
|
||||
|
||||
To keep the dialogs verbatim while avoiding import cycles with the list views,
|
||||
the few list classes they reference are imported lazily inside the methods that
|
||||
use them (see ``lists`` imports below).
|
||||
"""
|
||||
|
||||
import io
|
||||
import zlib
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...core.checkalive import CheckAliveError
|
||||
from ...core.checkalive import CheckAliveError, resolve_date_to_check
|
||||
from ...core.reminders import build_ics_reminders
|
||||
from ...core.qrtransfer import (
|
||||
CHUNK_PRESETS,
|
||||
MissingFramesError,
|
||||
QrTransferError,
|
||||
assemble,
|
||||
decode_transfer,
|
||||
encode_transfer,
|
||||
parse_frame,
|
||||
preset_index_for_chunk_size,
|
||||
split_frames,
|
||||
)
|
||||
from .calendar import BalCalendarButton
|
||||
from .common import (
|
||||
_,
|
||||
@@ -42,13 +61,18 @@ from .common import (
|
||||
NoHeirsException,
|
||||
NoWillExecutorNotPresent,
|
||||
NotCompleteWillException,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QTimer,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -57,16 +81,21 @@ from .common import (
|
||||
TxBroadcastError,
|
||||
TxFeesChangedException,
|
||||
Util,
|
||||
WaitingDialog,
|
||||
Will,
|
||||
WillExecutorFeeTooHighException,
|
||||
WillExecutorNotPresent,
|
||||
WillExpiredException,
|
||||
WillItem,
|
||||
WillPostponedException,
|
||||
WillexecutorChangeException,
|
||||
Willexecutors,
|
||||
bring_to_front,
|
||||
decimal_point_to_base_unit_name,
|
||||
draw_qr,
|
||||
export_meta_gui,
|
||||
import_meta_gui,
|
||||
log_error,
|
||||
partial,
|
||||
pyqtSignal,
|
||||
read_QIcon_from_bytes,
|
||||
@@ -76,6 +105,7 @@ from .common import (
|
||||
stop_thread,
|
||||
time,
|
||||
top_level_of,
|
||||
write_json_file,
|
||||
)
|
||||
from .widgets import (
|
||||
WillSettingsWidget,
|
||||
@@ -2363,3 +2393,811 @@ class HeirsDialog(BalDialog, MessageBoxMixin):
|
||||
def closeEvent(self, event):
|
||||
event.accept()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# QR / audio will transfer
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class BalQrImage(QWidget):
|
||||
"""A widget that renders one QR code, scaled to its own size.
|
||||
|
||||
Electrum's own ``QRCodeWidget`` hard-codes the LOW error-correction level,
|
||||
which is fine for a one-shot payload but risky for long multi-frame will
|
||||
transfers. This widget renders a fresh code on every paint with MEDIUM
|
||||
correction using Electrum's :func:`draw_qr` paint helper.
|
||||
"""
|
||||
|
||||
def __init__(self, text="", parent=None):
|
||||
QWidget.__init__(self, parent)
|
||||
self.text = text
|
||||
self.setMinimumSize(240, 240)
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
)
|
||||
|
||||
def set_text(self, text):
|
||||
self.text = text
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
# Imported lazily: qrcode is shipped with Electrum but it is not a Qt
|
||||
# widget, so keeping it out of the hub import keeps dialogs importable
|
||||
# even when qrcode itself is missing at import time.
|
||||
import qrcode
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
error_correction=qrcode.ERROR_CORRECT_M, border=2
|
||||
)
|
||||
qr.add_data(self.text)
|
||||
qr.make(fit=True)
|
||||
draw_qr(
|
||||
qr=qr, paint_device=self, is_enabled=True, min_boxsize=2
|
||||
)
|
||||
QWidget.paintEvent(self, event)
|
||||
|
||||
|
||||
class WillQrExportDialog(BalDialog):
|
||||
"""Export a will as a sequence of QR codes, one per screen.
|
||||
|
||||
The chosen transactions are serialized with the wire format of
|
||||
:mod:`bal.core.qrtransfer` and split into frames of the configured chunk
|
||||
size. The user walks the frames with Prev/Next arrows; the chunk size can
|
||||
be changed live from the standard presets. An "Audio" send button is shown
|
||||
when Electrum's ``audio_modem`` plugin is available (raw newline-joined
|
||||
transactions, no BAL framing, since the audio transport zlib-compresses
|
||||
internally).
|
||||
"""
|
||||
|
||||
def __init__(self, bal_window, will=None, bal_plugin=None):
|
||||
BalDialog.__init__(
|
||||
self, bal_window.window, bal_plugin, _("Export will via QR codes")
|
||||
)
|
||||
self.bal_window = bal_window
|
||||
try:
|
||||
chunk = int(bal_window.bal_plugin.QR_CHUNK_SIZE.get())
|
||||
except Exception:
|
||||
chunk = CHUNK_PRESETS[0][1]
|
||||
self.chunk_size = chunk
|
||||
self._source = will if will is not None else bal_window.willitems
|
||||
# Export filters: which will items the QR transfer includes. Mirrors
|
||||
# the All / Valid / Valid-NC choices of the "Export" file menu.
|
||||
self._filters = [
|
||||
(_("All"), lambda wi: True),
|
||||
(_("Valid"), lambda wi: wi.get_status("VALID")),
|
||||
(
|
||||
_("Valid NC"),
|
||||
lambda wi: wi.get_status("VALID") and not wi.get_status("COMPLETE"),
|
||||
),
|
||||
]
|
||||
self._filter_index = 0
|
||||
self.auto_timer = QTimer(self)
|
||||
self.auto_timer.timeout.connect(self._auto_step)
|
||||
self._build_transfer(self._filtered_willitems())
|
||||
if not self.tx_strings:
|
||||
self.show_message(_("No will transaction to export."))
|
||||
self.close()
|
||||
return
|
||||
self.index = 0
|
||||
vbox = QVBoxLayout(self)
|
||||
|
||||
self.intro_label = QLabel()
|
||||
self._update_intro()
|
||||
vbox.addWidget(self.intro_label)
|
||||
|
||||
filter_row = QHBoxLayout()
|
||||
filter_row.addWidget(QLabel(_("Export:")))
|
||||
self.filter_combo = QComboBox()
|
||||
self.filter_combo.addItems([label for label, _fn in self._filters])
|
||||
self.filter_combo.currentIndexChanged.connect(self._on_filter_change)
|
||||
filter_row.addWidget(self.filter_combo)
|
||||
filter_row.addStretch(1)
|
||||
vbox.addLayout(filter_row)
|
||||
|
||||
self.qr_view = BalQrImage(parent=self)
|
||||
self.qr_view.set_text(self.frames[self.index])
|
||||
vbox.addWidget(self.qr_view)
|
||||
|
||||
self.progress_label = QLabel()
|
||||
vbox.addWidget(self.progress_label)
|
||||
|
||||
nav = QHBoxLayout()
|
||||
self.prev_btn = QPushButton(_("Previous"))
|
||||
self.prev_btn.clicked.connect(self._prev)
|
||||
nav.addWidget(self.prev_btn)
|
||||
self.next_btn = QPushButton(_("Next"))
|
||||
self.next_btn.clicked.connect(self._next)
|
||||
nav.addWidget(self.next_btn)
|
||||
nav.addStretch(1)
|
||||
|
||||
if bal_window.get_audio_modem_plugin() is not None:
|
||||
audio_btn = QPushButton(_("Audio…"))
|
||||
audio_btn.setToolTip(
|
||||
_("Send the export over your speaker (Audio MODEM plugin).")
|
||||
)
|
||||
audio_btn.clicked.connect(self._audio_send)
|
||||
nav.addWidget(audio_btn)
|
||||
|
||||
vbox.addLayout(nav)
|
||||
|
||||
auto_row = QHBoxLayout()
|
||||
self.auto_btn = QPushButton(_("Auto"))
|
||||
self.auto_btn.setToolTip(
|
||||
_("Automatically advance through the QR codes.")
|
||||
)
|
||||
self.auto_btn.clicked.connect(self._toggle_auto)
|
||||
auto_row.addWidget(self.auto_btn)
|
||||
auto_row.addWidget(QLabel(_("QR codes per second:")))
|
||||
self.fps_spin = QSpinBox()
|
||||
self.fps_spin.setRange(1, 10)
|
||||
self.fps_spin.setValue(1)
|
||||
self.fps_spin.setSuffix(_(" /s"))
|
||||
auto_row.addWidget(self.fps_spin)
|
||||
self.loop_check = QCheckBox(_("Loop"))
|
||||
self.loop_check.setToolTip(
|
||||
_("When the last QR code is reached, keep cycling from the "
|
||||
"first one instead of stopping.")
|
||||
)
|
||||
auto_row.addWidget(self.loop_check)
|
||||
auto_row.addStretch(1)
|
||||
vbox.addLayout(auto_row)
|
||||
|
||||
size_row = QHBoxLayout()
|
||||
size_row.addWidget(QLabel(_("QR code size:")))
|
||||
self.size_combo = QComboBox()
|
||||
self.size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
|
||||
self.size_combo.setCurrentIndex(
|
||||
preset_index_for_chunk_size(self.chunk_size)
|
||||
)
|
||||
self.size_combo.currentIndexChanged.connect(self._on_chunk_change)
|
||||
size_row.addWidget(self.size_combo)
|
||||
size_row.addStretch(1)
|
||||
vbox.addLayout(size_row)
|
||||
|
||||
close_btn = QPushButton(_("Close"))
|
||||
close_btn.clicked.connect(self.close)
|
||||
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self._render()
|
||||
|
||||
def _build_transfer(self, willitems):
|
||||
"""Serialize the chosen transactions into frame-ready state."""
|
||||
items = sorted(willitems.values(), key=lambda wi: str(wi.tx.txid()))
|
||||
self.tx_strings = [str(wi.tx) for wi in items]
|
||||
self.audio_payload = "\n".join(self.tx_strings)
|
||||
self.transfer = encode_transfer(self.tx_strings, compress=False)
|
||||
self._refresh_frames()
|
||||
self.total_frames = len(self.frames)
|
||||
|
||||
def _refresh_frames(self):
|
||||
self.frames = split_frames(
|
||||
self.transfer, self.chunk_size, compressed=False
|
||||
)
|
||||
self.index = 0
|
||||
|
||||
def _on_chunk_change(self, index):
|
||||
self._stop_auto()
|
||||
self.chunk_size = CHUNK_PRESETS[index][1]
|
||||
self._refresh_frames()
|
||||
self._render()
|
||||
|
||||
def _toggle_auto(self):
|
||||
"""Start/stop the automatic QR slideshow."""
|
||||
if self.auto_timer.isActive():
|
||||
self._stop_auto()
|
||||
return
|
||||
if len(self.frames) <= 1:
|
||||
return
|
||||
fps = self.fps_spin.value()
|
||||
if fps <= 0:
|
||||
return
|
||||
self.auto_btn.setText(_("Stop"))
|
||||
self.auto_timer.start(int(1000 / fps))
|
||||
|
||||
def _stop_auto(self):
|
||||
if self.auto_timer.isActive():
|
||||
self.auto_timer.stop()
|
||||
self.auto_btn.setText(_("Auto"))
|
||||
|
||||
def _auto_step(self):
|
||||
if self.index >= len(self.frames) - 1:
|
||||
# Reach the last code: loop back or stop the slideshow.
|
||||
if self.loop_check.isChecked():
|
||||
self.index = 0
|
||||
self._render()
|
||||
return
|
||||
self._stop_auto()
|
||||
return
|
||||
self._next()
|
||||
|
||||
def _filtered_willitems(self):
|
||||
"""The will items selected by the current export filter."""
|
||||
_label, fn = self._filters[self._filter_index]
|
||||
return {wid: wi for wid, wi in self._source.items() if fn(wi)}
|
||||
|
||||
def _update_intro(self):
|
||||
self.intro_label.setText(
|
||||
_(
|
||||
"Scan the QR codes below, in order, with the will-opening "
|
||||
"device.\nFrame 1 of {} carries the total number of codes."
|
||||
).format(self.total_frames)
|
||||
)
|
||||
|
||||
def _on_filter_change(self, index):
|
||||
previous = self._filter_index
|
||||
self._filter_index = index
|
||||
self._stop_auto()
|
||||
if not self._filtered_willitems():
|
||||
# The selection is empty under the new filter: revert and inform.
|
||||
self.filter_combo.blockSignals(True)
|
||||
self.filter_combo.setCurrentIndex(previous)
|
||||
self.filter_combo.blockSignals(False)
|
||||
self._filter_index = previous
|
||||
self.show_message(_("No will transaction matches the selected filter."))
|
||||
return
|
||||
self._build_transfer(self._filtered_willitems())
|
||||
self._update_intro()
|
||||
self._render()
|
||||
|
||||
def _prev(self):
|
||||
if self.index > 0:
|
||||
self.index -= 1
|
||||
self._render()
|
||||
|
||||
def _next(self):
|
||||
if self.index < len(self.frames) - 1:
|
||||
self.index += 1
|
||||
self._render()
|
||||
|
||||
def _render(self):
|
||||
self.qr_view.set_text(self.frames[self.index])
|
||||
self.progress_label.setText(
|
||||
_("Frame {} of {}").format(self.index + 1, len(self.frames))
|
||||
)
|
||||
self.prev_btn.setEnabled(self.index > 0)
|
||||
self.next_btn.setEnabled(self.index < len(self.frames) - 1)
|
||||
|
||||
def _audio_send(self):
|
||||
try:
|
||||
self.bal_window._audio_send_payload(self.audio_payload)
|
||||
except Exception as e:
|
||||
log_error(e, self)
|
||||
self.show_error(str(e))
|
||||
|
||||
|
||||
class WillQrImportDialog(BalDialog):
|
||||
"""Import a will by scanning its QR codes (or receiving it by audio).
|
||||
|
||||
Frames are captured one by one from the camera (or typed manually). The
|
||||
first frame fixes the total frame count and the transfer compression flag;
|
||||
the slot grid shows which frames are still missing. When every frame is
|
||||
present the "Review and Sign" button assembles the transfer, decodes it
|
||||
into transactions and hands them to :class:`WillTxReviewSignDialog`. The
|
||||
audio path is one-shot (no BAL framing) and jumps straight to the wizard.
|
||||
All work happens on a local copy; the live will is never touched.
|
||||
"""
|
||||
|
||||
def __init__(self, bal_window, bal_plugin=None):
|
||||
BalDialog.__init__(
|
||||
self, bal_window.window, bal_plugin, _("Import will via QR codes")
|
||||
)
|
||||
self.bal_window = bal_window
|
||||
self.frames = {}
|
||||
self.total = 0
|
||||
self.compressed = False
|
||||
self._scanning = False
|
||||
self.slot_widgets = {}
|
||||
|
||||
vbox = QVBoxLayout(self)
|
||||
intro = QLabel(
|
||||
_(
|
||||
"Scan the QR codes printed by the will-opening device, one "
|
||||
"shot at a time.\nDuplicates are ignored; the first frame "
|
||||
"sets the total number of codes."
|
||||
)
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
vbox.addWidget(intro)
|
||||
|
||||
self.status_label = QLabel(_("Waiting for the first frame…"))
|
||||
vbox.addWidget(self.status_label)
|
||||
|
||||
# Slot grid inside a scroll area (a large will can need many frames).
|
||||
self.slot_widget = QWidget()
|
||||
self.slot_grid = QGridLayout(self.slot_widget)
|
||||
self.slot_grid.setSpacing(4)
|
||||
self.slot_area = QScrollArea()
|
||||
self.slot_area.setWidget(self.slot_widget)
|
||||
self.slot_area.setWidgetResizable(True)
|
||||
self.slot_area.setMaximumHeight(180)
|
||||
self.slot_area.setVisible(False)
|
||||
vbox.addWidget(self.slot_area)
|
||||
|
||||
manual = QHBoxLayout()
|
||||
self.manual_edit = QLineEdit()
|
||||
self.manual_edit.setPlaceholderText(
|
||||
_("…or paste/type the frame text here")
|
||||
)
|
||||
self.manual_edit.returnPressed.connect(self._add_from_manual)
|
||||
manual.addWidget(self.manual_edit)
|
||||
manual_btn = QPushButton(_("Add frame"))
|
||||
manual_btn.clicked.connect(self._add_from_manual)
|
||||
manual.addWidget(manual_btn)
|
||||
vbox.addLayout(manual)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.scan_btn = QPushButton(_("Scan QR with camera"))
|
||||
self.scan_btn.clicked.connect(self._scan_camera)
|
||||
buttons.addWidget(self.scan_btn)
|
||||
if bal_window.get_audio_modem_plugin() is not None:
|
||||
audio_btn = QPushButton(_("Receive by audio…"))
|
||||
audio_btn.clicked.connect(self._audio_receive)
|
||||
buttons.addWidget(audio_btn)
|
||||
self.reset_btn = QPushButton(_("Reset"))
|
||||
self.reset_btn.clicked.connect(self._reset_all)
|
||||
buttons.addWidget(self.reset_btn)
|
||||
buttons.addStretch(1)
|
||||
vbox.addLayout(buttons)
|
||||
|
||||
bottom = QHBoxLayout()
|
||||
self.review_btn = QPushButton(_("Review and Sign…"))
|
||||
self.review_btn.setEnabled(False)
|
||||
self.review_btn.clicked.connect(self._review_and_sign)
|
||||
bottom.addWidget(self.review_btn)
|
||||
bottom.addStretch(1)
|
||||
close_btn = QPushButton(_("Close"))
|
||||
close_btn.clicked.connect(self.close)
|
||||
bottom.addWidget(close_btn)
|
||||
vbox.addLayout(bottom)
|
||||
|
||||
# -- frame handling -------------------------------------------------------
|
||||
|
||||
def _add_from_manual(self):
|
||||
text = self.manual_edit.text().strip()
|
||||
if text:
|
||||
self.manual_edit.clear()
|
||||
self._add_frame(text)
|
||||
|
||||
def _add_frame(self, frame_text):
|
||||
try:
|
||||
total, index, compressed, payload = parse_frame(frame_text)
|
||||
except QrTransferError as e:
|
||||
self.show_error(str(e))
|
||||
return
|
||||
if self.total and total != self.total:
|
||||
# A different total means a different transfer: wipe and restart.
|
||||
self._reset_all()
|
||||
self.show_warning(
|
||||
_(
|
||||
"The scanned code belongs to a different transfer ({} "
|
||||
"frames). The import was reset; scan the first code again."
|
||||
).format(total)
|
||||
)
|
||||
return
|
||||
if not self.total:
|
||||
self.total = total
|
||||
self.compressed = compressed
|
||||
self._init_slots()
|
||||
self.frames[index] = payload
|
||||
self._update_slots()
|
||||
self._update_status()
|
||||
|
||||
def _init_slots(self):
|
||||
while self.slot_grid.count():
|
||||
item = self.slot_grid.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
self.slot_grid.removeItem(item)
|
||||
self.slot_widgets = {}
|
||||
for index in range(1, self.total + 1):
|
||||
b = QPushButton(str(index))
|
||||
b.setEnabled(False)
|
||||
row = (index - 1) // 8
|
||||
col = (index - 1) % 8
|
||||
self.slot_grid.addWidget(b, row, col)
|
||||
self.slot_widgets[index] = b
|
||||
self.slot_area.setVisible(True)
|
||||
|
||||
def _update_slots(self):
|
||||
for index, b in self.slot_widgets.items():
|
||||
present = index in self.frames
|
||||
b.setStyleSheet(
|
||||
"QPushButton{background-color:#90ee90;}" if present else ""
|
||||
)
|
||||
|
||||
def _update_status(self):
|
||||
have = len(self.frames)
|
||||
if have >= self.total:
|
||||
self.status_label.setText(_("All {} frames stored.").format(self.total))
|
||||
self.review_btn.setEnabled(True)
|
||||
else:
|
||||
self.status_label.setText(
|
||||
_("Stored {} of {} frames.").format(have, self.total)
|
||||
)
|
||||
self.review_btn.setEnabled(False)
|
||||
|
||||
def _reset_all(self):
|
||||
self.frames = {}
|
||||
self.total = 0
|
||||
self.compressed = False
|
||||
if self.slot_widgets:
|
||||
for b in self.slot_widgets.values():
|
||||
b.deleteLater()
|
||||
self.slot_widgets = {}
|
||||
self.slot_area.setVisible(False)
|
||||
self.review_btn.setEnabled(False)
|
||||
self.status_label.setText(_("Waiting for the first frame…"))
|
||||
|
||||
# -- capture --------------------------------------------------------------
|
||||
|
||||
def _scan_camera(self):
|
||||
if self._scanning:
|
||||
return
|
||||
from electrum.gui.qt.qrreader import scan_qrcode_from_camera
|
||||
|
||||
self._scanning = True
|
||||
self.scan_btn.setEnabled(False)
|
||||
|
||||
def callback(success, error, data):
|
||||
self._scanning = False
|
||||
self.scan_btn.setEnabled(True)
|
||||
if success and data:
|
||||
self._add_frame(data)
|
||||
elif not success and error:
|
||||
self.show_error(str(error))
|
||||
|
||||
try:
|
||||
scan_qrcode_from_camera(
|
||||
parent=self,
|
||||
config=self.bal_window.window.config,
|
||||
callback=callback,
|
||||
)
|
||||
except Exception as e:
|
||||
self._scanning = False
|
||||
self.scan_btn.setEnabled(True)
|
||||
self.show_error(str(e))
|
||||
|
||||
def _audio_receive(self):
|
||||
"""Receive a transfer over the audio_modem plugin (raw tx list).
|
||||
|
||||
The audio transport compresses internally and carries no BAL framing,
|
||||
so this mirrors the plugin's own ``_recv`` (which hard-wires a
|
||||
``setText`` sink) but delivers the decoded text through a callback.
|
||||
"""
|
||||
plugin = self.bal_window.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
self.show_error(_("Audio MODEM plugin is not available."))
|
||||
return
|
||||
try:
|
||||
import amodem # noqa: F401 # type: ignore (guaranteed by is_available)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
return
|
||||
|
||||
def receiver_thread():
|
||||
with plugin._audio_interface() as interface:
|
||||
src = interface.recorder()
|
||||
dst = io.BytesIO()
|
||||
amodem.main.recv(config=plugin.modem_config, src=src, dst=dst)
|
||||
return dst.getvalue()
|
||||
|
||||
def on_success(blob):
|
||||
if not blob:
|
||||
return
|
||||
try:
|
||||
text = zlib.decompress(blob).decode("ascii")
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
return
|
||||
tx_strings = [
|
||||
part for part in text.split("\n") if part and part.strip()
|
||||
]
|
||||
if not tx_strings:
|
||||
self.show_error(_("No transaction data received."))
|
||||
return
|
||||
self._finish_import(tx_strings)
|
||||
|
||||
kbps = plugin.modem_config.modem_bps / 1e3
|
||||
WaitingDialog(
|
||||
self,
|
||||
_("Waiting for audio ({:.1f} kbps)…").format(kbps),
|
||||
receiver_thread,
|
||||
on_success,
|
||||
)
|
||||
|
||||
# -- finish ---------------------------------------------------------------
|
||||
|
||||
def _review_and_sign(self):
|
||||
try:
|
||||
transfer = assemble(self.frames, self.total)
|
||||
tx_strings = decode_transfer(transfer, self.compressed)
|
||||
except (MissingFramesError, QrTransferError) as e:
|
||||
self.show_error(str(e))
|
||||
return
|
||||
if not tx_strings:
|
||||
self.show_error(_("The transferred will contains no transactions."))
|
||||
return
|
||||
self._finish_import(tx_strings)
|
||||
|
||||
def _finish_import(self, tx_strings):
|
||||
"""Build local WillItems and open the review+sign wizard."""
|
||||
items = {}
|
||||
for s in tx_strings:
|
||||
try:
|
||||
wi = WillItem({"tx": s}, wallet=self.bal_window.wallet)
|
||||
except Exception as e:
|
||||
self.show_error(
|
||||
_("Could not parse a transferred transaction: {}").format(e)
|
||||
)
|
||||
return
|
||||
items[wi._id] = wi
|
||||
Will.normalize_will(items, self.bal_window.wallet)
|
||||
self._local_validity_pass(items)
|
||||
for wi in items.values():
|
||||
wi.set_status("IMPORTED", True)
|
||||
valid = [wid for wid in items if items[wid].get_status("VALID")]
|
||||
if not valid:
|
||||
self.show_error(
|
||||
_(
|
||||
"The imported will contains no valid transaction in this "
|
||||
"wallet."
|
||||
)
|
||||
)
|
||||
self.close()
|
||||
return
|
||||
self.close()
|
||||
skipped = len(items) - len(valid)
|
||||
if skipped:
|
||||
self.show_warning(
|
||||
_(
|
||||
"{} imported transaction(s) are not valid in this wallet "
|
||||
"and were skipped."
|
||||
).format(skipped)
|
||||
)
|
||||
wizard = WillTxReviewSignDialog(
|
||||
self.bal_window, will=items, bal_plugin=self.bal_plugin
|
||||
)
|
||||
if wizard.aborted:
|
||||
return
|
||||
show_on_top(wizard)
|
||||
|
||||
def _local_validity_pass(self, items):
|
||||
"""Local, wallet-only validity check (no server, no expiry raise).
|
||||
|
||||
Mirrors the check that :meth:`BalWindow.merge_will` runs after a merge
|
||||
so the import and the file-merge paths behave identically.
|
||||
"""
|
||||
date_to_check = getattr(self.bal_window, "date_to_check", None)
|
||||
if date_to_check is None:
|
||||
date_to_check = resolve_date_to_check(
|
||||
self.bal_window.bal_plugin.is_basic_mode(),
|
||||
self.bal_window.will_settings,
|
||||
)
|
||||
history_label = self.bal_window.bal_plugin.HISTORY_LABEL.get()
|
||||
try:
|
||||
Will.add_willtree(items)
|
||||
all_utxos = Util.get_available_utxos(
|
||||
self.bal_window.wallet,
|
||||
history_label,
|
||||
Will.get_min_locktime(items, default_value=date_to_check),
|
||||
)
|
||||
Will.check_invalidated(
|
||||
items, Will.utxos_strs(all_utxos), self.bal_window.wallet
|
||||
)
|
||||
Will.search_rai(
|
||||
Will.get_all_inputs(items, only_valid=True),
|
||||
all_utxos,
|
||||
items,
|
||||
self.bal_window.wallet,
|
||||
)
|
||||
Will.check_signatures(items, self.bal_window.wallet)
|
||||
except Exception as e:
|
||||
log_error(e, self)
|
||||
|
||||
|
||||
class WillTxReviewSignDialog(BalDialog):
|
||||
"""Per-transaction review + sign wizard for an imported will.
|
||||
|
||||
Walks the (valid) imported transactions one at a time showing outputs,
|
||||
total outputs and fees, with Sign / Skip / Cancel per page. All signing
|
||||
runs on the local copy of the imported will; the live will and the wallet
|
||||
history are never touched. The final page offers to export the signed
|
||||
transactions as a file and/or as QR codes.
|
||||
"""
|
||||
|
||||
def __init__(self, bal_window, will=None, bal_plugin=None):
|
||||
BalDialog.__init__(
|
||||
self, bal_window.window, bal_plugin, _("Review and sign imported will")
|
||||
)
|
||||
self.bal_window = bal_window
|
||||
self.items = will if will is not None else bal_window.willitems
|
||||
self.txids = sorted(Will.only_valid(self.items))
|
||||
self.aborted = False
|
||||
self.i = 0
|
||||
if not self.txids:
|
||||
self.aborted = True
|
||||
self.close()
|
||||
return
|
||||
self.password = bal_window.get_wallet_password(
|
||||
message=_(
|
||||
"Enter your wallet password to sign the imported transactions."
|
||||
)
|
||||
)
|
||||
if self.password is False:
|
||||
self.aborted = True
|
||||
self.close()
|
||||
return
|
||||
|
||||
vbox = QVBoxLayout(self)
|
||||
self.stack = QStackedWidget(self)
|
||||
self.summary_page = self._build_summary_page()
|
||||
# Index 0 = summary page; review pages start at index 1.
|
||||
self.stack.addWidget(self.summary_page)
|
||||
self.review_pages = []
|
||||
for _txid in self.txids:
|
||||
page = self._build_review_page()
|
||||
self.stack.addWidget(page)
|
||||
self.review_pages.append(page)
|
||||
vbox.addWidget(self.stack)
|
||||
self.stack.setCurrentIndex(1)
|
||||
self._render()
|
||||
|
||||
# -- page builders ---------------------------------------------------------
|
||||
|
||||
def _build_review_page(self):
|
||||
page = QWidget()
|
||||
vbox = QVBoxLayout(page)
|
||||
header = QLabel()
|
||||
vbox.addWidget(header)
|
||||
txid_label = QLabel()
|
||||
txid_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
vbox.addWidget(txid_label)
|
||||
outputs_label = QLabel()
|
||||
outputs_label.setTextInteractionFlags(
|
||||
Qt.TextInteractionFlag.TextSelectableByMouse
|
||||
)
|
||||
vbox.addWidget(outputs_label)
|
||||
totals_label = QLabel()
|
||||
vbox.addWidget(totals_label)
|
||||
|
||||
row = QHBoxLayout()
|
||||
sign_btn = QPushButton(_("Sign"))
|
||||
sign_btn.clicked.connect(self._sign_current)
|
||||
row.addWidget(sign_btn)
|
||||
skip_btn = QPushButton(_("Skip"))
|
||||
skip_btn.clicked.connect(self._advance)
|
||||
row.addWidget(skip_btn)
|
||||
cancel_btn = QPushButton(_("Cancel"))
|
||||
cancel_btn.clicked.connect(self.close)
|
||||
row.addWidget(cancel_btn)
|
||||
row.addStretch(1)
|
||||
vbox.addLayout(row)
|
||||
return page
|
||||
|
||||
def _build_summary_page(self):
|
||||
page = QWidget()
|
||||
vbox = QVBoxLayout(page)
|
||||
self.summary_label = QLabel()
|
||||
self.summary_label.setWordWrap(True)
|
||||
vbox.addWidget(self.summary_label)
|
||||
save_btn = QPushButton(_("Save signed file…"))
|
||||
save_btn.clicked.connect(self._save_signed)
|
||||
vbox.addWidget(save_btn)
|
||||
self.qr_btn = QPushButton(_("Show signed QR…"))
|
||||
self.qr_btn.clicked.connect(self._show_signed_qr)
|
||||
vbox.addWidget(self.qr_btn)
|
||||
close_btn = QPushButton(_("Close"))
|
||||
close_btn.clicked.connect(self.close)
|
||||
vbox.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)
|
||||
return page
|
||||
|
||||
# -- rendering -------------------------------------------------------------
|
||||
|
||||
def _page_index(self):
|
||||
return 0 if self.i >= len(self.txids) else self.i + 1
|
||||
|
||||
def _render(self):
|
||||
if self.i >= len(self.txids):
|
||||
self._enter_summary()
|
||||
return
|
||||
txid = self.txids[self.i]
|
||||
wi = self.items[txid]
|
||||
tx = wi.tx
|
||||
page = self.review_pages[self.i]
|
||||
|
||||
headers = page.findChildren(QLabel)
|
||||
headers[0].setText(
|
||||
_("Transaction {} of {}").format(self.i + 1, len(self.txids))
|
||||
)
|
||||
headers[1].setText(_("TXID: {}").format(txid))
|
||||
lines = []
|
||||
for o in tx.outputs():
|
||||
value = o.value if o.value is not None else _("unknown")
|
||||
if isinstance(value, int):
|
||||
value_s = self.bal_window.window.format_amount_and_units(value)
|
||||
else:
|
||||
value_s = value
|
||||
lines.append("{} {}".format(o.get_ui_address_str(), value_s))
|
||||
headers[2].setText("\n".join(lines) if lines else _("(no outputs)"))
|
||||
total_out = sum(
|
||||
(o.value or 0) for o in tx.outputs() if isinstance(o.value, int)
|
||||
)
|
||||
fee = None
|
||||
try:
|
||||
iv = tx.input_value()
|
||||
if isinstance(iv, int):
|
||||
fee = iv - total_out
|
||||
except Exception:
|
||||
fee = None
|
||||
if fee is not None:
|
||||
fee_s = self.bal_window.window.format_amount_and_units(fee)
|
||||
else:
|
||||
fee_s = _("unknown (partial transaction)")
|
||||
headers[3].setText(
|
||||
_("Total outputs: {}\nFee: {}").format(
|
||||
self.bal_window.window.format_amount_and_units(total_out), fee_s
|
||||
)
|
||||
)
|
||||
self.stack.setCurrentIndex(self._page_index())
|
||||
|
||||
def _enter_summary(self):
|
||||
signed = sum(
|
||||
1 for txid in self.txids if self.items[txid].get_status("COMPLETE")
|
||||
)
|
||||
self.summary_label.setText(
|
||||
_(
|
||||
"Signed {} of {} transactions.\n\nSave a signed file to carry "
|
||||
"to the broadcast device, or show the signed transactions as "
|
||||
"QR codes."
|
||||
).format(signed, len(self.txids))
|
||||
)
|
||||
self.qr_btn.setEnabled(signed > 0)
|
||||
self.stack.setCurrentIndex(0)
|
||||
|
||||
# -- actions ---------------------------------------------------------------
|
||||
|
||||
def _sign_current(self):
|
||||
txid = self.txids[self.i]
|
||||
try:
|
||||
tx, newly = self.bal_window._prepare_and_sign_tx(
|
||||
self.items, txid, self.password
|
||||
)
|
||||
except Exception as e:
|
||||
log_error(e, self)
|
||||
self.show_error(_("Could not sign the transaction: {}").format(e))
|
||||
return
|
||||
if newly and tx.is_complete():
|
||||
self.items[txid].set_status("COMPLETE", True)
|
||||
self._advance()
|
||||
|
||||
def _advance(self):
|
||||
self.i += 1
|
||||
self._render()
|
||||
|
||||
def _save_signed(self):
|
||||
data = {wid: wi.to_dict() for wid, wi in self.items.items()}
|
||||
|
||||
def _do_save(path):
|
||||
try:
|
||||
write_json_file(path, data)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
return
|
||||
self.show_message(_("Signed will saved."))
|
||||
|
||||
export_meta_gui(self.bal_window.window, "will_signed.json", _do_save)
|
||||
|
||||
def _show_signed_qr(self):
|
||||
signed = {
|
||||
wid: wi
|
||||
for wid, wi in self.items.items()
|
||||
if wid in self.txids and wi.get_status("COMPLETE")
|
||||
}
|
||||
if not signed:
|
||||
self.show_message(_("No signed transaction to show."))
|
||||
return
|
||||
d = WillQrExportDialog(self.bal_window, will=signed, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user