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:
@@ -279,6 +279,13 @@ class BalPlugin(BasePlugin):
|
||||
# stay display-only outside the wizard unless the user opts in.
|
||||
self.EDITABLE_DATES = BalConfig(config, "bal_editable_dates", False)
|
||||
|
||||
# QR_CHUNK_SIZE (will transfer via QR): payload budget, in bytes, used
|
||||
# per QR frame when exporting/importing a will through the QR channel.
|
||||
# The settings dialog offers the 4 standard presets of
|
||||
# bal.core.qrtransfer.CHUNK_PRESETS; this stores the selected budget.
|
||||
# Default 150 (small QR, low-resolution cameras).
|
||||
self.QR_CHUNK_SIZE = BalConfig(config, "bal_qr_chunk_size", 150)
|
||||
|
||||
# NUM_REMINDERS (Group D / D1): how many SEPARATE reminder events the
|
||||
# exported .ics calendar should contain. Each reminder becomes its own
|
||||
# VEVENT (its own date in the calendar). The dates are spread uniformly
|
||||
|
||||
212
bal/core/qrtransfer.py
Normal file
212
bal/core/qrtransfer.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
bal.core.qrtransfer
|
||||
===================
|
||||
|
||||
GUI-free helpers for moving BAL will data between devices via QR codes or
|
||||
the Electrum ``audio_modem`` plugin (see ``PLAN_QR_TRANSFER.md``).
|
||||
|
||||
Scope
|
||||
-----
|
||||
* converts will transactions into a compact ``transfer_string``
|
||||
(newline-joined serialized transactions, optionally zlib + base64
|
||||
compressed);
|
||||
* splits that string into fixed-size ``BALQR1|N|i|flags|payload`` frames for
|
||||
multi-QR export, and reassembles/validates them on import.
|
||||
|
||||
The audio-modem channel deliberately bypasses the framing helpers here
|
||||
(PLAN_QR_TRANSFER.md section 4.4): its transport compresses internally and
|
||||
carries the whole transfer string in a single blob, so callers only use
|
||||
:func:`encode_transfer` / :func:`decode_transfer`.
|
||||
|
||||
This module never imports Qt or any Electrum GUI code (house rule).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
MAGIC = "BALQR"
|
||||
VERSION = 1
|
||||
FLAG_COMPRESSED = "Z"
|
||||
|
||||
# 4 standard presets (label, payload budget in bytes per QR). Ordered from
|
||||
# low-resolution cameras to high-resolution cameras (owner decision D5).
|
||||
CHUNK_PRESETS = (
|
||||
("Small - ~150 bytes/QR (low-res cameras)", 150),
|
||||
("Medium - ~400 bytes/QR", 400),
|
||||
("Large - ~900 bytes/QR", 900),
|
||||
("XL - ~1800 bytes/QR (high-res cameras)", 1800),
|
||||
)
|
||||
|
||||
# Smallest allowed payload budget per frame, below which the frame header
|
||||
# could consume the whole budget.
|
||||
MIN_CHUNK_SIZE = 40
|
||||
|
||||
_FRAME_MAGIC = MAGIC + str(VERSION)
|
||||
|
||||
|
||||
class QrTransferError(ValueError):
|
||||
"""Base error for will QR / audio transfer processing."""
|
||||
|
||||
|
||||
class MissingFramesError(QrTransferError):
|
||||
"""Some frame indices of a multi-QR transfer are missing."""
|
||||
|
||||
def __init__(self, missing):
|
||||
self.missing = list(missing)
|
||||
super().__init__("Missing QR frames: {}".format(self.missing))
|
||||
|
||||
|
||||
class InconsistentTotalError(QrTransferError):
|
||||
"""Frames disagree about the advertised frame total."""
|
||||
|
||||
|
||||
def encode_transfer(tx_strings, compress=False):
|
||||
"""Join serialized transaction strings into a transfer string.
|
||||
|
||||
``compress=True`` wraps the joined text in zlib + base64 (ASCII-safe) so
|
||||
the whole bundle shrinks before being printed/scanned. The optional flags
|
||||
of the frame header let the importer reverse this automatically.
|
||||
"""
|
||||
return __compress("\n".join(tx_strings), enabled=compress)
|
||||
|
||||
|
||||
def decode_transfer(transfer_string, compressed):
|
||||
"""Inverse of :func:`encode_transfer`.
|
||||
|
||||
Returns the list of serialized transaction strings; empty frames are
|
||||
dropped so a trailing newline (or an empty payload) cannot produce an
|
||||
empty trailing element.
|
||||
"""
|
||||
text = __decompress(transfer_string, enabled=compressed)
|
||||
return [part for part in text.split("\n") if part]
|
||||
|
||||
|
||||
def split_frames(transfer_string, chunk_size, compressed=False):
|
||||
"""Split ``transfer_string`` into full ``BALQR`` frames.
|
||||
|
||||
Every returned frame is at most ``chunk_size`` characters long (header
|
||||
included). ``compressed`` propagates the ``Z`` flag into every frame so
|
||||
the importer knows how to reverse the encoding.
|
||||
|
||||
Raises :class:`QrTransferError` when ``chunk_size`` is too small to hold
|
||||
the header plus any payload.
|
||||
"""
|
||||
flags = FLAG_COMPRESSED if compressed else ""
|
||||
total = __compute_total(len(transfer_string), chunk_size, flags)
|
||||
frames = []
|
||||
pos = 0
|
||||
length = len(transfer_string)
|
||||
for index in range(1, total + 1):
|
||||
overhead = len(__frame_header(total, index, flags))
|
||||
budget = chunk_size - overhead
|
||||
end = min(pos + budget, length)
|
||||
frames.append(__build_frame(total, index, flags, transfer_string[pos:end]))
|
||||
pos = end
|
||||
if pos >= length:
|
||||
break
|
||||
if pos < length:
|
||||
# __compute_total guarantees this cannot happen; keep a safety net.
|
||||
raise QrTransferError("internal error: frames did not cover the transfer string")
|
||||
return frames
|
||||
|
||||
|
||||
def parse_frame(frame):
|
||||
"""Parse a single frame.
|
||||
|
||||
Returns ``(total, index, compressed: bool, payload: str)``. Raises
|
||||
:class:`QrTransferError` on malformed input (bad magic/version, wrong
|
||||
arity, non-integer or out-of-range frame numbers, unknown flags).
|
||||
"""
|
||||
parts = frame.split("|", maxsplit=4)
|
||||
if len(parts) != 5:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame structure)")
|
||||
magic_seen, total_s, index_s, flags, payload = parts
|
||||
if magic_seen != _FRAME_MAGIC:
|
||||
raise QrTransferError("Not a BAL will QR (unknown magic/version)")
|
||||
try:
|
||||
total = int(total_s)
|
||||
index = int(index_s)
|
||||
except ValueError as e:
|
||||
raise QrTransferError("Not a BAL will QR (bad frame numbers)") from e
|
||||
if total < 1 or not 1 <= index <= total:
|
||||
raise QrTransferError("Not a BAL will QR (frame numbering out of range)")
|
||||
if flags not in ("", FLAG_COMPRESSED):
|
||||
raise QrTransferError("Not a BAL will QR (unknown flags)")
|
||||
return total, index, flags == FLAG_COMPRESSED, payload
|
||||
|
||||
|
||||
def assemble(frames, total):
|
||||
"""Concatenate frame payloads back into a transfer string.
|
||||
|
||||
``frames`` maps 1-based index -> payload. Every index ``1..total`` must
|
||||
be present (else :class:`MissingFramesError`) and no index may exceed
|
||||
``total`` (else :class:`InconsistentTotalError`).
|
||||
"""
|
||||
if total < 1:
|
||||
raise QrTransferError("invalid frame total")
|
||||
missing = [index for index in range(1, total + 1) if index not in frames]
|
||||
if missing:
|
||||
raise MissingFramesError(missing)
|
||||
extra = [index for index in frames if index > total]
|
||||
if extra:
|
||||
raise InconsistentTotalError()
|
||||
return "".join(frames[index] for index in range(1, total + 1))
|
||||
|
||||
|
||||
def preset_index_for_chunk_size(chunk_size):
|
||||
"""Return the :data:`CHUNK_PRESETS` index whose budget best matches a size."""
|
||||
best, best_diff = 0, abs(chunk_size - CHUNK_PRESETS[0][1])
|
||||
for index, (_label, budget) in enumerate(CHUNK_PRESETS):
|
||||
diff = abs(chunk_size - budget)
|
||||
if diff < best_diff:
|
||||
best, best_diff = index, diff
|
||||
return best
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Internals
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def __compress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return base64.b64encode(zlib.compress(text.encode("utf-8"))).decode("ascii")
|
||||
|
||||
|
||||
def __decompress(text, *, enabled):
|
||||
if not enabled:
|
||||
return text
|
||||
return zlib.decompress(base64.b64decode(text.encode("ascii"))).decode("utf-8")
|
||||
|
||||
|
||||
def __frame_header(total, index, flags):
|
||||
return "{}|{}|{}|{}|".format(_FRAME_MAGIC, total, index, flags)
|
||||
|
||||
|
||||
def __build_frame(total, index, flags, payload):
|
||||
return __frame_header(total, index, flags) + payload
|
||||
|
||||
|
||||
def __compute_total(transfer_len, chunk_size, flags):
|
||||
"""Smallest frame count whose budget covers the whole transfer string.
|
||||
|
||||
The budget shrinks as ``total`` gains digits (wider header), so the count
|
||||
is recomputed iteratively until it converges.
|
||||
"""
|
||||
if chunk_size < MIN_CHUNK_SIZE:
|
||||
raise QrTransferError(
|
||||
"chunk size too small to hold a BAL QR frame: {}".format(chunk_size)
|
||||
)
|
||||
total = 1
|
||||
while True:
|
||||
overhead = len(__frame_header(total, total, flags))
|
||||
budget = chunk_size - overhead
|
||||
if budget <= 0:
|
||||
raise QrTransferError(
|
||||
"chunk size too small for the BAL QR frame header: {}".format(chunk_size)
|
||||
)
|
||||
if transfer_len <= budget * total:
|
||||
return total
|
||||
total += 1
|
||||
@@ -1338,7 +1338,7 @@ class WillItem(Logger):
|
||||
self.tx = Will.get_tx_from_any(w["tx"])
|
||||
self.heirs = w.get("heirs", None)
|
||||
self.we = w.get("willexecutor", None)
|
||||
self.status = w.get("status", None)
|
||||
self.status = w.get("status") or ""
|
||||
self.description = w.get("description", None)
|
||||
self.time = w.get("time", None)
|
||||
self.change = w.get("change", None)
|
||||
|
||||
@@ -28,6 +28,7 @@ from functools import partial
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
|
||||
from electrum.gui.common_qt.util import draw_qr
|
||||
from electrum.gui.qt.amountedit import BTCAmountEdit
|
||||
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
|
||||
from electrum.gui.qt.my_treeview import MyTreeView
|
||||
@@ -42,6 +43,7 @@ from electrum.gui.qt.util import (
|
||||
MessageBoxMixin,
|
||||
OkButton,
|
||||
TaskThread,
|
||||
WaitingDialog,
|
||||
WindowModalDialog,
|
||||
char_width_in_lineedit,
|
||||
getOpenFileName,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -667,7 +667,9 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
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)
|
||||
menu.addAction(_("Merge"), self.merge_will)
|
||||
menu.addAction(_("Broadcast"), self.broadcast)
|
||||
menu.addAction(_("Check"), self.check)
|
||||
@@ -769,6 +771,12 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
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()
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ from electrum.plugin import hook
|
||||
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,
|
||||
@@ -531,6 +533,21 @@ class Plugin(BalPlugin, EventListener):
|
||||
# users (BASIC and ADVANCED).
|
||||
heir_auto_rebuild = BalCheckBox(self.AUTO_REBUILD)
|
||||
|
||||
# QR Code Size selector (will transfer via QR). A 4-standard-size combo
|
||||
# bound to the QR_CHUNK_SIZE config (payload budget in bytes per frame).
|
||||
# Ordered low -> high so the user picks the resolution matching their
|
||||
# camera. Visible to all users (BASIC and ADVANCED).
|
||||
qr_size_combo = QComboBox()
|
||||
qr_size_combo.addItems([label for label, _budget in CHUNK_PRESETS])
|
||||
qr_size_combo.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(self.QR_CHUNK_SIZE.get()))
|
||||
)
|
||||
|
||||
def on_qr_size_change(index):
|
||||
self.QR_CHUNK_SIZE.set(CHUNK_PRESETS[index][1])
|
||||
|
||||
qr_size_combo.currentIndexChanged.connect(on_qr_size_change)
|
||||
|
||||
# USER TYPE selector (SIMPLE / ADVANCED, global). A two-choice combo
|
||||
# (not a free-text field) bound to the USER_TYPE config:
|
||||
# index 0 -> "BASIC" -> stored value "basic" (DEFAULT)
|
||||
@@ -647,6 +664,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
btn.clicked.connect(reset)
|
||||
return btn
|
||||
|
||||
@@ -905,6 +926,25 @@ class Plugin(BalPlugin, EventListener):
|
||||
)
|
||||
grid.addWidget(reset_btn_auto_rebuild, 15, 3)
|
||||
|
||||
# "QR Code Size" row (always visible, BASIC + ADVANCED). Default QR
|
||||
# size used when exporting a will via QR codes; changeable per export
|
||||
# inside the export dialog itself.
|
||||
lbl_qr_size = QLabel(_("QR Code Size"))
|
||||
help_qr_size = HelpButton(
|
||||
"Payload size of a single QR code when exporting a will via QR.\n\n"
|
||||
"Larger QR codes hold more data (fewer shots) but are easier to "
|
||||
"scan with a high-resolution camera; smaller QR codes scan fine "
|
||||
"even with low-resolution cameras but require more shots.\n"
|
||||
"The same selector is available inside the export dialog."
|
||||
)
|
||||
grid.addWidget(lbl_qr_size, 16, 0)
|
||||
grid.addWidget(qr_size_combo, 16, 1)
|
||||
grid.addWidget(help_qr_size, 16, 2)
|
||||
reset_btn_qr_size = _make_reset_btn(
|
||||
self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"
|
||||
)
|
||||
grid.addWidget(reset_btn_qr_size, 16, 3)
|
||||
|
||||
# ----------------------------------------------------------------- #
|
||||
# Group C / C4b: "Reset" button that restores the dialog settings to #
|
||||
# their factory defaults. It only resets the settings exposed by THIS #
|
||||
@@ -938,6 +978,7 @@ class Plugin(BalPlugin, EventListener):
|
||||
(self.HISTORY_LABEL, edit_history_label, "line"),
|
||||
(self.REBUILD_ON_CLOSE, heir_rebuild_on_close, "check"),
|
||||
(self.AUTO_REBUILD, heir_auto_rebuild, "check"),
|
||||
(self.QR_CHUNK_SIZE, qr_size_combo, "qr_size"),
|
||||
]
|
||||
for cfg, widget, kind in resets:
|
||||
# Persist the default value back into the Electrum config.
|
||||
@@ -958,6 +999,10 @@ class Plugin(BalPlugin, EventListener):
|
||||
widget.setCurrentIndex(
|
||||
1 if str(cfg.default).lower() == "advanced" else 0
|
||||
)
|
||||
elif kind == "qr_size":
|
||||
widget.setCurrentIndex(
|
||||
preset_index_for_chunk_size(int(cfg.default))
|
||||
)
|
||||
# Re-sync the history-label field's enabled state after a reset: the
|
||||
# reset restores SAVE_HISTORY to its default, so the field must
|
||||
# follow the (default) checkbox state again.
|
||||
|
||||
@@ -88,6 +88,8 @@ from .dialogs import (
|
||||
BalWizardDialog,
|
||||
WillDetailDialog,
|
||||
WillExecutorDialog,
|
||||
WillQrExportDialog,
|
||||
WillQrImportDialog,
|
||||
)
|
||||
from .lists import HeirListWidget, PreviewList
|
||||
from .widgets import LockTimeWidget, PercAmountEdit
|
||||
@@ -980,6 +982,13 @@ class BalWindow:
|
||||
return self.show_transaction_real(tx, parent=parent)
|
||||
|
||||
def invalidate_will(self, will=None):
|
||||
# The reference timestamp is normally set by init_class_variables();
|
||||
# fall back to "now" so a first-action invalidation always has it.
|
||||
if not hasattr(self, "date_to_check") or self.date_to_check is None:
|
||||
self.date_to_check = resolve_date_to_check(
|
||||
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||
)
|
||||
|
||||
def on_success(result):
|
||||
if result:
|
||||
self.show_message(
|
||||
@@ -1015,75 +1024,93 @@ class BalWindow:
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def sign_transactions(self, password, will=None, txids=None):
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
txs = {}
|
||||
signed = None
|
||||
tosign = None
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
txs = {}
|
||||
signed = None
|
||||
tosign = None
|
||||
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
def get_message():
|
||||
msg = ""
|
||||
if signed:
|
||||
msg = _(f"signed: {signed}\n")
|
||||
return msg + _(f"signing: {tosign}")
|
||||
|
||||
if txids is not None:
|
||||
targets = [
|
||||
t for t in txids
|
||||
if t in willitems and willitems[t].get_status("VALID")
|
||||
]
|
||||
else:
|
||||
targets = Will.only_valid(willitems)
|
||||
for txid in targets:
|
||||
wi = willitems[txid]
|
||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
||||
# from the serialized form instead, which is exactly how the will
|
||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
||||
tx = Will.get_tx_from_any(str(wi.tx))
|
||||
if wi.get_status("COMPLETE"):
|
||||
if txids is not None:
|
||||
targets = [
|
||||
t for t in txids
|
||||
if t in willitems and willitems[t].get_status("VALID")
|
||||
]
|
||||
else:
|
||||
targets = Will.only_valid(willitems)
|
||||
for txid in targets:
|
||||
wi = willitems[txid]
|
||||
if wi.get_status("COMPLETE"):
|
||||
# Already signed and complete: keep as-is (the single-tx
|
||||
# helper short-circuits without touching the wallet).
|
||||
tx, _ = self._prepare_and_sign_tx(willitems, txid, password)
|
||||
txs[txid] = tx
|
||||
continue
|
||||
tosign = txid
|
||||
try:
|
||||
self.waiting_dialog.update(get_message())
|
||||
except Exception:
|
||||
pass
|
||||
tx, _signed = self._prepare_and_sign_tx(willitems, txid, password)
|
||||
signed = tosign
|
||||
txs[txid] = tx
|
||||
continue
|
||||
tosign = txid
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
|
||||
def _prepare_and_sign_tx(self, willitems, txid, password):
|
||||
"""Prepare one will transaction and sign it.
|
||||
|
||||
Shared by the batch signer (:meth:`sign_transactions`) and the
|
||||
per-transaction review wizard of the QR import flow
|
||||
(:class:`WillTxReviewSignDialog`).
|
||||
|
||||
Returns ``(tx, newly_signed)``: ``newly_signed`` is False when the
|
||||
transaction was already COMPLETE (nothing was signed).
|
||||
"""
|
||||
wi = willitems[txid]
|
||||
# Do NOT deepcopy: the stored tx carries wallet-derived objects
|
||||
# (utxo / script_descriptor) that hold a threading.RLock, and
|
||||
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
|
||||
# from the serialized form instead, which is exactly how the will
|
||||
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
|
||||
tx = Will.get_tx_from_any(str(wi.tx))
|
||||
if wi.get_status("COMPLETE"):
|
||||
return tx, False
|
||||
for txin in tx.inputs():
|
||||
prevout = txin.prevout.to_json()
|
||||
if prevout[0] in willitems:
|
||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||
txin._trusted_value_sats = change.value
|
||||
try:
|
||||
self.waiting_dialog.update(get_message())
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
except Exception:
|
||||
pass
|
||||
for txin in tx.inputs():
|
||||
prevout = txin.prevout.to_json()
|
||||
if prevout[0] in willitems:
|
||||
change = willitems[prevout[0]].tx.outputs()[prevout[1]]
|
||||
txin._trusted_value_sats = change.value
|
||||
try:
|
||||
txin.script_descriptor = change.script_descriptor
|
||||
except Exception:
|
||||
pass
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin.is_mine = True
|
||||
txin._TxInput__address = change.address
|
||||
txin._TxInput__scriptpubkey = change.scriptpubkey
|
||||
txin._TxInput__value_sats = change.value
|
||||
txin._trusted_value_sats = change.value
|
||||
|
||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||
signed = tosign
|
||||
# is_complete = False
|
||||
if tx.is_complete():
|
||||
# is_complete = True
|
||||
wi.set_status("COMPLETE", True)
|
||||
# Refresh the per-item signature counts from the freshly signed
|
||||
# partial tx: at this point the signatures are still present
|
||||
# (before any finalization), so the will list can show the real
|
||||
# "added/required" count (e.g. "1/2" for a multisig).
|
||||
try:
|
||||
have, required = tx.signature_count()
|
||||
wi.sigs_have = int(have)
|
||||
wi.sigs_required = int(required)
|
||||
except Exception as e:
|
||||
_logger.debug(f"signature_count after signing failed: {e}")
|
||||
txs[txid] = tx
|
||||
except Exception:
|
||||
return None
|
||||
return txs
|
||||
self.wallet.sign_transaction(tx, password, ignore_warnings=True)
|
||||
if tx.is_complete():
|
||||
wi.set_status("COMPLETE", True)
|
||||
# Refresh the per-item signature counts from the freshly signed
|
||||
# partial tx: at this point the signatures are still present
|
||||
# (before any finalization), so the will list can show the real
|
||||
# "added/required" count (e.g. "1/2" for a multisig).
|
||||
try:
|
||||
have, required = tx.signature_count()
|
||||
wi.sigs_have = int(have)
|
||||
wi.sigs_required = int(required)
|
||||
except Exception as e:
|
||||
_logger.debug(f"signature_count after signing failed: {e}")
|
||||
return tx, True
|
||||
|
||||
def get_wallet_password(self, message=None, parent=None):
|
||||
parent = self.window if not parent else parent
|
||||
@@ -1620,6 +1647,52 @@ 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.
|
||||
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
willitems = will if will is not None else self.willitems
|
||||
d = WillQrExportDialog(self, will=willitems, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
except Exception as e:
|
||||
self.show_error(str(e))
|
||||
raise e
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
"""Return Electrum's ``audio_modem`` plugin instance, or None.
|
||||
|
||||
The plugin is only usable when Electrum exposes it (the ``Plugins``
|
||||
manager knows the name) and its optional runtime dependency
|
||||
``amodem`` is installed (:meth:`is_available`). Every other case
|
||||
returns None so callers can simply hide the audio buttons.
|
||||
"""
|
||||
try:
|
||||
p = self.window.gui_object.plugins.get("audio_modem")
|
||||
except Exception:
|
||||
return None
|
||||
if not p or not getattr(p, "is_available", lambda: False)():
|
||||
return None
|
||||
return p
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
"""Send a transfer payload through the audio_modem plugin.
|
||||
|
||||
Wraps the plugin's own ``_send`` with a proper parent widget. The
|
||||
audio channel zlib-compresses internally, so the payload is passed
|
||||
uncompressed (no BAL ``Z`` flag needed on that transport).
|
||||
"""
|
||||
plugin = self.get_audio_modem_plugin()
|
||||
if plugin is None:
|
||||
self.show_error(_("Audio MODEM plugin is not available."))
|
||||
return
|
||||
plugin._send(parent=self.window, blob=payload)
|
||||
|
||||
def merge_will(self, imported):
|
||||
"""Merge imported will items into the live will.
|
||||
|
||||
@@ -1762,6 +1835,18 @@ 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.
|
||||
|
||||
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`).
|
||||
"""
|
||||
d = WillQrImportDialog(self, bal_plugin=self.bal_plugin)
|
||||
show_on_top(d)
|
||||
|
||||
def _load_will_file(self, path):
|
||||
data = read_json_file(path)
|
||||
willitems = {}
|
||||
|
||||
Reference in New Issue
Block a user