feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests

This commit is contained in:
2026-09-14 09:11:50 -04:00
parent 9c4697c923
commit fb88d7540c
82 changed files with 11665 additions and 3116 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
@@ -28,6 +27,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 +42,7 @@ from electrum.gui.qt.util import (
MessageBoxMixin,
OkButton,
TaskThread,
WaitingDialog,
WindowModalDialog,
char_width_in_lineedit,
getOpenFileName,
@@ -80,6 +81,7 @@ from PyQt6.QtWidgets import (
QAbstractItemView,
QAbstractSpinBox,
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDateTimeEdit,
@@ -92,6 +94,7 @@ from PyQt6.QtWidgets import (
QMenu,
QMenuBar,
QPushButton,
QRadioButton,
QScrollArea,
QSizePolicy,
QSpinBox,
@@ -119,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,11 +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)
menu.addAction(_("Import"), self.import_will_into_details)
# 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)
@@ -733,38 +733,11 @@ 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()

View File

@@ -19,9 +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,
BalPlugin,
Buttons,
EnterButton,
@@ -38,6 +37,8 @@ from .common import (
QWidget,
UserCancelled,
Willexecutors,
_,
_logger,
add_widget,
partial,
read_QIcon_from_bytes,
@@ -531,6 +532,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 +663,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 +925,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 +977,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 +998,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.

View File

@@ -20,6 +20,7 @@ Contents:
from typing import TYPE_CHECKING
from ...core.heirs import get_op_return_hex, is_op_return_address
from ...core.input_rules import (
LockTimeEditor,
normalize_locktime_raw_text,
@@ -29,17 +30,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 +56,15 @@ from .common import (
QSpinBox,
QStyle,
QStyleOptionFrame,
Qt,
QTextEdit,
QVBoxLayout,
QWidget,
Qt,
Union,
Util,
Will,
_,
_logger,
char_width_in_lineedit,
datetime,
getSaveFileName,
@@ -1331,14 +1332,28 @@ class WillWidget(QWidget):
)
detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs:
if 'w!ll3x3c"' not in heir:
decoded_amount = Util.decode_amount(
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
)
for heir_name in self.will[w].heirs:
if 'w!ll3x3c"' in heir_name:
continue
h = self.will[w].heirs[heir_name]
decoded_amount = Util.decode_amount(
h[3], self._bal_parent.decimal_point
)
if is_op_return_address(h[0]):
data_hex = get_op_return_hex(h[0]) or ""
try:
decoded = bytes.fromhex(data_hex).decode(
"utf-8", errors="replace"
)
except Exception:
decoded = h[0]
detaillayout.addWidget(qlabel(heir_name, "OP_RETURN: " + decoded))
else:
detaillayout.addWidget(
qlabel(
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
heir_name,
f"{decoded_amount} {self._bal_parent.base_unit_name} "
f"[{h[0]}]",
)
)
if self.will[w].we:
@@ -1354,6 +1369,10 @@ class WillWidget(QWidget):
f"{decoded_amount} {self._bal_parent.base_unit_name}",
)
)
if self.will[w].we.get("address"):
detaillayout.addWidget(
qlabel(_("Address"), self.will[w].we["address"])
)
detaillayout.addStretch()
pal = QPalette()
pal.setColor(

View File

@@ -23,10 +23,10 @@ from ...core.checkalive import (
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
resolve_guard_threshold,
)
from .common import (
_,
_logger,
OP_RETURN_PREFIX,
AmountException,
BalPlugin,
Buttons,
@@ -40,10 +40,10 @@ from .common import (
Mapping,
Network,
NoHeirsException,
NoWillExecutorNotPresent,
NotCompleteWillException,
OP_RETURN_PREFIX,
NoWillExecutorNotPresent,
OkButton,
Optional,
PaymentIdentifier,
QGridLayout,
QLabel,
@@ -57,15 +57,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 +75,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,6 +90,10 @@ from .dialogs import (
BalWizardDialog,
WillDetailDialog,
WillExecutorDialog,
WillExportDialog,
WillImportDialog,
_complete_import,
decode_will_payload,
)
from .lists import HeirListWidget, PreviewList
from .widgets import LockTimeWidget, PercAmountEdit
@@ -461,6 +467,31 @@ class BalWindow:
def build_will(self, ignore_duplicate=True, keep_original=True):
_logger.debug("building will...")
# Drop stale wallet-LOCAL will placeholders saved by previous prepares
# so their coins are available to this build (see remove_stale...).
Will.remove_stale_wallet_history(
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
)
# A (re)build may have anticipated the delivery (shorter heir recipes)
# while ``date_to_check`` is still anchored to the OLD built will. Using
# that stale anchor as the build filter would block every future
# delivery ("NO_FUTURE_DATE"). Recompute ``date_to_check`` for the will
# that is being built: its locktime is the earliest future delivery
# among the CURRENT heirs. The checks of the EXISTING will keep their
# anchored ``date_to_check`` (set in init_class_variables).
_new_locktime = min(
(
Util.parse_locktime_string(h[2])
for h in self.heirs.values()
),
default=None,
)
if _new_locktime:
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(),
self.will_settings,
built_locktime=_new_locktime,
)
will = {}
# willtodelete = []
# willtoappend = {}
@@ -515,11 +546,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)
@@ -740,6 +771,27 @@ class BalWindow:
raise e
def is_locktime_below_threshold(self) -> bool:
"""True when the stored settings make the delivery earlier than the
Check Alive threshold (the "locktime is lower than threshold" guard).
Compares the delivery against the settings-derived threshold on the
SAME reference frame (see ``resolve_guard_threshold``), never against
the built-will-anchored ``date_to_check``: anchoring the guard to an
old, longer built will would wrongly fire right after the delivery was
shortened. The anchored reference still governs the validity and
expiry checks, which is where ``date_to_check`` belongs.
In BASIC mode there is no threshold, so the locktime is checked against
``date_to_check`` (= now) exactly as before.
"""
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
threshold_ts = resolve_guard_threshold(
self.bal_plugin.is_basic_mode(), self.will_settings
)
if threshold_ts is not None:
return locktime < threshold_ts
return self.date_to_check is not None and locktime < self.date_to_check
def build_inheritance_transaction(self, ignore_duplicate=True, keep_original=True):
try:
_logger.info(
@@ -752,6 +804,11 @@ class BalWindow:
if not self.heirs:
_logger.warning("not heirs {}".format(self.heirs))
return
# Free the coins locked by stale wallet-LOCAL will placeholders
# BEFORE the amount/UTXO checks below (Step 1) see them.
Will.remove_stale_wallet_history(
self.window.wallet, self.bal_plugin.HISTORY_LABEL.get()
)
try:
self.init_class_variables()
Will.check_amounts(
@@ -786,8 +843,7 @@ class BalWindow:
)
)
return
locktime = Util.parse_locktime_string(self.will_settings["locktime"])
if locktime < self.date_to_check:
if self.is_locktime_below_threshold():
self.show_error(_("locktime is lower than threshold"))
return
if not self.no_willexecutor:
@@ -980,6 +1036,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 +1078,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
@@ -1611,6 +1692,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(
@@ -1620,6 +1714,73 @@ class BalWindow:
self.show_error(str(e))
raise e
def export_will_dialog(self, will=None, initial_mode: Optional[str] = None):
"""Open the unified export window (File / QR / Audio).
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 = 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))
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 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.
@@ -1743,16 +1904,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:
@@ -1762,6 +1941,18 @@ class BalWindow:
import_meta_gui(self.window, _("will"), on_file, on_success)
def import_will_dialog(self):
"""Open the unified import window (File / QR / Audio).
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 = WillImportDialog(self, bal_plugin=self.bal_plugin)
show_on_top(d)
def _load_will_file(self, path):
data = read_json_file(path)
willitems = {}
@@ -1770,6 +1961,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