feat: Introduce dynamic parameter handling for device menu layouts and longer lifetime
What changed, and why it matters
This commit is a user-interface refactor for the Trezor hardware wallet's device menu. It changes how the menu receives updated information (like Bluetooth connection status) so the menu can refresh itself without closing and reopening. There is no direct evidence in the commit that this fixes a security vulnerability; it appears to be a code-quality and user-experience improvement.
Treat as a normal feature/refactor commit. No immediate security action is indicated. If auditing, verify that `DeviceMenuParams::try_from` validates all inputs correctly and that `ParamsObj` is never retained past the event pass, as the comments require.
Security signals we found
Refactor of UI parameter handling with no changelog entry
Use of `unwrap!` on parsed parameters in `device_menu_screen.rs` update path
New MicroPython-to-Rust object passing path (`ParamsObj`) with explicit lifetime/ownership comments
Removal of `RefreshMenu` result message and replacement with in-place refresh
Evidence from the diff
The patch introduces a generic parameter-refresh mechanism for Rust UI layouts driven from MicroPython. It adds ParamsObj, Event::UpdateParams, EventCtx::request_params(), and LayoutObj::needs_params_refresh()/update_params(). The device menu is converted to use a single DeviceMenuParams struct parsed from a MicroPython dict, and the RefreshMenu message is removed in favor of in-place updates triggered by USB/BLE configuration-change events. The Python side adds a params_provider hook and a DeviceMenuLayout subclass that supplies fresh parameters after each handler. The change touches 15 files and is marked [no changelog].
Changed components
core/embed/rust/src/ui/component/base.rscore/embed/rust/src/ui/layout/obj.rscore/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rscore/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rscore/embed/rust/src/ui/ui_firmware.rscore/embed/rust/src/ui/api/firmware_micropython.rscore/src/apps/homescreen/device_menu.pycore/src/trezor/ui/__init__.pyInspect captured patch +567 / −419
### core/embed/rust/librust_qstr.h
@@ -61,7 +61,6 @@ static void _librust_qstrs(void) {
MP_QSTR_RX_PACKET_LEN;
MP_QSTR_Reboot;
MP_QSTR_RebootToBootloader;
- MP_QSTR_RefreshMenu;
MP_QSTR_RemovePin;
MP_QSTR_RemoveWipeCode;
MP_QSTR_ReviewFailedBackup;
@@ -532,6 +531,7 @@ static void _librust_qstrs(void) {
MP_QSTR_n1w1__hold_next;
MP_QSTR_n1w1__reading;
MP_QSTR_n1w1__writing;
+ MP_QSTR_needs_params_refresh;
MP_QSTR_next_timeout;
MP_QSTR_notification;
MP_QSTR_packet_in;
@@ -981,6 +981,7 @@ static void _librust_qstrs(void) {
MP_QSTR_type_for_name;
MP_QSTR_type_for_wire;
MP_QSTR_unpair;
+ MP_QSTR_update_params;
MP_QSTR_usb_event;
MP_QSTR_user_fee_change;
MP_QSTR_value;
### core/embed/rust/src/ui/api/firmware_micropython.rs
@@ -25,7 +25,7 @@ use crate::ui::layout::util::{upy_disable_animation, RecoveryType};
use crate::ui::notification::{Notification, NotificationLevel, NOTIFICATION_LEVEL_OBJ};
use crate::ui::ui_firmware::{
FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
@@ -909,85 +909,13 @@ extern "C" fn new_show_homescreen(n_args: usize, args: *const Obj, kwargs: *mut
unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
}
-extern "C" fn new_show_device_menu(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj {
- let block = move |_args: &[Obj], kwargs: &Map| {
- let init_submenu_idx: Option<u8> = kwargs
- .get(Qstr::MP_QSTR_init_submenu_idx)?
- .try_into_option()?;
- let init_submenu_offset: i16 = kwargs.get(Qstr::MP_QSTR_init_submenu_offset)?.try_into()?;
- let backup_failed: bool = kwargs.get(Qstr::MP_QSTR_backup_failed)?.try_into()?;
- let backup_needed: bool = kwargs.get(Qstr::MP_QSTR_backup_needed)?.try_into()?;
- let ble_enabled: bool = kwargs.get(Qstr::MP_QSTR_ble_enabled)?.try_into()?;
- let paired_obj: Obj = kwargs.get(Qstr::MP_QSTR_paired_devices)?;
- let mut paired_devices: heapless::Vec<
- (TString<'static>, Option<[TString; 2]>),
- MAX_PAIRED_DEVICES,
- > = heapless::Vec::new();
- for device in IterBuf::new().try_iterate(paired_obj)? {
- let [mac, host_info]: [Obj; 2] = util::iter_into_array(device)?;
- let mac: TString<'static> = mac.try_into()?;
- let host_info: Option<[TString<'static>; 2]> = host_info
- .try_into_option()?
- .map(util::iter_into_array)
- .transpose()?;
-
- if paired_devices.push((mac, host_info)).is_err() {
- return Err(Error::OutOfRange);
- }
- }
- let connected_idx: Option<u8> =
- kwargs.get(Qstr::MP_QSTR_connected_idx)?.try_into_option()?;
- let pin_enabled: Option<bool> = kwargs.get(Qstr::MP_QSTR_pin_enabled)?.try_into_option()?;
- let auto_lock: Option<[TString; 2]> = kwargs
- .get(Qstr::MP_QSTR_auto_lock)?
- .try_into_option()?
- .map(util::iter_into_array)
- .transpose()?;
- let wipe_code_enabled: Option<bool> = kwargs
- .get(Qstr::MP_QSTR_wipe_code_enabled)?
- .try_into_option()?;
- let backup_check_allowed: bool =
- kwargs.get(Qstr::MP_QSTR_backup_check_allowed)?.try_into()?;
- let device_name: Option<TString> =
- kwargs.get(Qstr::MP_QSTR_device_name)?.try_into_option()?;
- let brightness: Option<TString> =
- kwargs.get(Qstr::MP_QSTR_brightness)?.try_into_option()?;
- let tap_to_wake_enabled: Option<bool> = kwargs
- .get(Qstr::MP_QSTR_tap_to_wake_enabled)?
- .try_into_option()?;
- let haptics_enabled: Option<bool> = kwargs
- .get(Qstr::MP_QSTR_haptics_enabled)?
- .try_into_option()?;
- let led_enabled: Option<bool> = kwargs.get(Qstr::MP_QSTR_led_enabled)?.try_into_option()?;
- let about_items: Obj = kwargs.get(Qstr::MP_QSTR_about_items)?;
- let production_year: Option<TString> = kwargs
- .get(Qstr::MP_QSTR_production_year)?
- .try_into_option()?;
- let layout = ModelUI::show_device_menu(
- init_submenu_idx,
- init_submenu_offset,
- backup_failed,
- backup_needed,
- ble_enabled,
- paired_devices,
- connected_idx,
- pin_enabled,
- auto_lock,
- wipe_code_enabled,
- backup_check_allowed,
- device_name,
- brightness,
- tap_to_wake_enabled,
- haptics_enabled,
- led_enabled,
- about_items,
- production_year,
- )?;
-
+extern "C" fn new_show_device_menu(params: Obj) -> Obj {
+ let block = || {
+ let layout = ModelUI::show_device_menu(params.try_into()?)?;
let layout_obj = LayoutObj::new_root(layout)?;
Ok(layout_obj.into())
};
- unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) }
+ unsafe { util::try_or_raise(block) }
}
extern "C" fn new_show_pairing_device_name(
@@ -1435,6 +1363,20 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// def button_request(self) -> tuple[ButtonRequestType, str] | None:
/// """Return (code, type) of button request made during the last event or timer pass."""
///
+ /// def needs_params_refresh(self) -> bool:
+ /// """Return True if the layout is waiting for fresh construction
+ /// parameters.
+ ///
+ /// The request stays pending until `update_params()` serves it.
+ /// """
+ ///
+ /// def update_params(self, params: Mapping[str, Any]) -> LayoutState | None:
+ /// """Hand fresh construction parameters to the layout.
+ ///
+ /// `params` takes the same keys the layout was constructed with. The
+ /// layout updates itself in place, without being restarted.
+ /// """
+ ///
/// def get_transition_out(self) -> AttachType:
/// """Return the transition type."""
///
@@ -1965,29 +1907,42 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// """Idle homescreen."""
Qstr::MP_QSTR_show_homescreen => obj_fn_kw!(0, new_show_homescreen).as_obj(),
+ /// class DeviceMenuParams(TypedDict):
+ /// """Everything the device menu is built from.
+ ///
+ /// The same set opens the menu and refreshes a running one through
+ /// `LayoutObj.update_params`. A refresh always carries the complete set
+ /// and rebuilds the menu from it; there is no partial update or diff, so
+ /// every key is always present. A value of `None` therefore means "not
+ /// applicable on this device", never "unchanged".
+ /// """
+ ///
+ /// init_submenu_idx: int | None
+ /// init_submenu_offset: int
+ /// backup_failed: bool
+ /// backup_needed: bool
+ /// ble_enabled: bool
+ /// paired_devices: Iterable[tuple[str, tuple[str, str] | None]]
+ /// connected_idx: int | None
+ /// pin_enabled: bool | None
+ /// auto_lock: tuple[str, str] | None
+ /// wipe_code_enabled: bool | None
+ /// backup_check_allowed: bool
+ /// device_name: str | None
+ /// brightness: str | None
+ /// tap_to_wake_enabled: bool | None
+ /// haptics_enabled: bool | None
+ /// led_enabled: bool | None
+ /// about_items: Sequence[tuple[str | None, StrOrBytes | None, bool | None]]
+ /// production_year: str | None
+ ///
+ /// mock:global
+ ///
/// def show_device_menu(
- /// *,
- /// init_submenu_idx: int | None,
- /// init_submenu_offset: int,
- /// backup_failed: bool,
- /// backup_needed: bool,
- /// ble_enabled: bool,
- /// paired_devices: Iterable[tuple[str, tuple[str, str] | None]],
- /// connected_idx: int | None,
- /// pin_enabled: bool | None,
- /// auto_lock: tuple[str, str] | None,
- /// wipe_code_enabled: bool | None,
- /// backup_check_allowed: bool,
- /// device_name: str | None,
- /// brightness: str | None,
- /// tap_to_wake_enabled: bool | None,
- /// haptics_enabled: bool | None,
- /// led_enabled: bool | None,
- /// about_items: Sequence[tuple[str | None, StrOrBytes | None, bool | None]],
- /// production_year: str | None,
+ /// params: DeviceMenuParams,
/// ) -> LayoutContext[tuple[str, int | None, int, int]]:
/// """Show the device menu. Result is a tuple (action, action_arg, next_menu_id, next_menu_offset)."""
- Qstr::MP_QSTR_show_device_menu => obj_fn_kw!(0, new_show_device_menu).as_obj(),
+ Qstr::MP_QSTR_show_device_menu => obj_fn_1!(new_show_device_menu).as_obj(),
/// def show_pairing_device_name(
/// *,
@@ -2235,6 +2190,5 @@ pub static mp_module_trezorui_api: Module = obj_module! {
/// Reboot: ClassVar[str]
/// RebootToBootloader: ClassVar[str]
/// TurnOff: ClassVar[str]
- /// RefreshMenu: ClassVar[str]
Qstr::MP_QSTR_DeviceMenuResult => DEVICE_MENU_RESULT.as_obj(),
};
### core/embed/rust/src/ui/component/base.rs
@@ -311,6 +311,48 @@ pub enum AttachType {
Swipe(Direction),
}
+/// Fresh construction parameters handed to a layout by the application layer,
+/// in response to a `EventCtx::request_params()` request.
+///
+/// Opaque on purpose: the concrete shape of the parameters is known only to the
+/// component that asked for them, which unpacks the wrapped MicroPython object
+/// itself.
+///
+/// Ownership stays with the caller. The object belongs to the application layer
+/// that passed it to `LayoutObj.update_params`, which keeps it alive for the
+/// duration of that call and no longer. A handler may read the parameters and
+/// copy what it needs out of them; it must not retain the object past the event
+/// pass, and must not mutate it - the object is the caller's, and writing to it
+/// would change what the application layer still holds.
+#[cfg(feature = "micropython")]
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub struct ParamsObj(crate::micropython::obj::Obj);
+
+#[cfg(feature = "micropython")]
+impl ParamsObj {
+ /// Crate-private: the only legitimate source of parameters is
+ /// `LayoutObj::obj_update_params`, so components can receive and read them
+ /// but nothing outside can mint them from an arbitrary object.
+ pub(crate) fn new(obj: crate::micropython::obj::Obj) -> Self {
+ Self(obj)
+ }
+
+ /// The wrapped object, to be unpacked within this event pass.
+ pub fn obj(&self) -> crate::micropython::obj::Obj {
+ self.0
+ }
+}
+
+#[cfg(all(feature = "micropython", feature = "debug"))]
+impl ufmt::uDebug for ParamsObj {
+ fn fmt<W>(&self, f: &mut ufmt::Formatter<'_, W>) -> Result<(), W::Error>
+ where
+ W: ufmt::uWrite + ?Sized,
+ {
+ f.write_str("ParamsObj")
+ }
+}
+
#[derive(Copy, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "debug", derive(ufmt::derive::uDebug))]
pub enum Event {
@@ -336,6 +378,11 @@ pub enum Event {
/// prepare for painting and/or start their timers.
/// This event is sent once before any other events.
Attach(AttachType),
+ /// The application layer supplies fresh construction parameters, previously
+ /// asked for via `EventCtx::request_params()`. Components that request
+ /// params are responsible for unpacking and applying them.
+ #[cfg(feature = "micropython")]
+ UpdateParams(ParamsObj),
/// Internally-handled event to inform all `Child` wrappers in a sub-tree to
/// get scheduled for painting.
RequestPaint,
@@ -471,6 +518,7 @@ pub struct EventCtx {
root_repaint_requested: bool,
swipe_disable_req: bool,
swipe_enable_req: bool,
+ params_requested: bool,
}
impl EventCtx {
@@ -494,6 +542,7 @@ impl EventCtx {
root_repaint_requested: false,
swipe_disable_req: false,
swipe_enable_req: false,
+ params_requested: false,
}
}
@@ -564,6 +613,22 @@ impl EventCtx {
self.button_request.take()
}
+ /// Ask the application layer for fresh construction parameters. The layout
+ /// keeps running; the params arrive later as an `Event::UpdateParams`.
+ ///
+ /// Use this instead of returning a "please restart me" message when only
+ /// the layout's inputs went stale -- it avoids tearing the layout down and
+ /// redrawing it from scratch.
+ pub fn request_params(&mut self) {
+ self.params_requested = true;
+ }
+
+ /// Returns `true` if a component asked for fresh construction parameters
+ /// during this event pass.
+ pub fn params_requested(&self) -> bool {
+ self.params_requested
+ }
+
pub fn pop_timer(&mut self) -> Option<(TimerToken, Duration)> {
self.timers.pop()
}
### core/embed/rust/src/ui/layout/device_menu_result.rs
@@ -40,9 +40,6 @@ pub enum DeviceMenuMsg {
ToggleHaptics,
ToggleLed,
WipeDevice,
-
- // Misc
- RefreshMenu,
}
impl DeviceMenuMsg {
@@ -71,7 +68,6 @@ impl DeviceMenuMsg {
Self::ToggleHaptics => Qstr::MP_QSTR_ToggleHaptics,
Self::ToggleLed => Qstr::MP_QSTR_ToggleLed,
Self::WipeDevice => Qstr::MP_QSTR_WipeDevice,
- Self::RefreshMenu => Qstr::MP_QSTR_RefreshMenu,
}
.to_obj()
}
@@ -111,7 +107,6 @@ static DEVICE_MENU_RESULT_TYPE: FullType = obj_type! {
Qstr::MP_QSTR_ToggleHaptics => Qstr::MP_QSTR_ToggleHaptics.to_obj(),
Qstr::MP_QSTR_ToggleLed => Qstr::MP_QSTR_ToggleLed.to_obj(),
Qstr::MP_QSTR_WipeDevice => Qstr::MP_QSTR_WipeDevice.to_obj(),
- Qstr::MP_QSTR_RefreshMenu => Qstr::MP_QSTR_RefreshMenu.to_obj(),
}),
};
### core/embed/rust/src/ui/layout/obj.rs
@@ -24,7 +24,7 @@ use crate::micropython::{util, Error};
#[cfg(feature = "button")]
use crate::trezorhal::button::{PhysicalButton, PhysicalButtonEvent};
use crate::ui::button_request::ButtonRequest;
-use crate::ui::component::base::{AttachType, TimerToken};
+use crate::ui::component::base::{AttachType, ParamsObj, TimerToken};
use crate::ui::component::{Component, Event, EventCtx, Never};
use crate::ui::display::{self, Color};
#[cfg(feature = "ble")]
@@ -198,6 +198,7 @@ struct LayoutObjInner {
repaint: Repaint,
transition_out: AttachType,
button_request: Option<ButtonRequest>,
+ params_requested: bool,
}
const NO_LAYOUT: Error = Error::RuntimeError(c"No layout");
@@ -216,6 +217,7 @@ impl LayoutObjInner {
repaint: Repaint::Full,
transition_out: AttachType::Initial,
button_request: None,
+ params_requested: false,
};
// invoke the initial placement
@@ -310,6 +312,12 @@ impl LayoutObjInner {
self.page_count = count;
}
+ // Remember a request for fresh construction parameters, to be picked up by
+ // the application layer after this event is handled.
+ if self.event_ctx.params_requested() {
+ self.params_requested = true;
+ }
+
msg.try_into()
}
@@ -362,6 +370,30 @@ impl LayoutObjInner {
}
}
+ /// Whether a component is waiting for fresh construction parameters.
+ ///
+ /// The request stays pending until `obj_update_params` serves it, so a
+ /// layout that asks while no parameters can be supplied keeps asking.
+ fn obj_needs_params_refresh(&self) -> Obj {
+ self.params_requested.into()
+ }
+
+ fn obj_update_params(&mut self, params: Obj) -> Result<Obj, Error> {
+ // A component may ask for another round while handling `UpdateParams`,
+ // and `obj_event` raises the flag at the end of the pass - so the
+ // pending request is cleared before the event is sent.
+ let pending = core::mem::replace(&mut self.params_requested, false);
+
+ let result = self.obj_event(Event::UpdateParams(ParamsObj::new(params)));
+
+ if result.is_err() {
+ // The pass did not complete, so the parameters were never delivered.
+ // Leave the request standing rather than dropping it silently.
+ self.params_requested = pending;
+ }
+ result
+ }
+
fn obj_get_transition_out(&self) -> Obj {
self.transition_out.to_obj()
}
@@ -412,6 +444,8 @@ impl LayoutObj {
Qstr::MP_QSTR___del__ => obj_fn_1!(ui_layout_delete).as_obj(),
Qstr::MP_QSTR_page_count => obj_fn_1!(ui_layout_page_count).as_obj(),
Qstr::MP_QSTR_button_request => obj_fn_1!(ui_layout_button_request).as_obj(),
+ Qstr::MP_QSTR_needs_params_refresh => obj_fn_1!(ui_layout_needs_params_refresh).as_obj(),
+ Qstr::MP_QSTR_update_params => obj_fn_2!(ui_layout_update_params).as_obj(),
Qstr::MP_QSTR_get_transition_out => obj_fn_1!(ui_layout_get_transition_out).as_obj(),
Qstr::MP_QSTR_return_value => obj_fn_1!(ui_layout_return_value).as_obj(),
@@ -666,6 +700,24 @@ extern "C" fn ui_layout_button_request(this: Obj) -> Obj {
unsafe { util::try_or_raise(block) }
}
+extern "C" fn ui_layout_needs_params_refresh(this: Obj) -> Obj {
+ let block = || {
+ let this: Gc<LayoutObj> = this.try_into()?;
+ let requested = this.inner_mut().obj_needs_params_refresh();
+ Ok(requested)
+ };
+ unsafe { util::try_or_raise(block) }
+}
+
+extern "C" fn ui_layout_update_params(this: Obj, params: Obj) -> Obj {
+ let block = || {
+ let this: Gc<LayoutObj> = this.try_into()?;
+ let msg = this.inner_mut().obj_update_params(params);
+ msg
+ };
+ unsafe { util::try_or_raise(block) }
+}
+
extern "C" fn ui_layout_get_transition_out(this: Obj) -> Obj {
let block = || {
let this: Gc<LayoutObj> = this.try_into()?;
### core/embed/rust/src/ui/layout_bolt/ui_firmware.rs
@@ -31,8 +31,8 @@ use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ DeviceMenuParams, FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES,
+ MAX_MENU_ITEMS, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::{geometry, ModelUI};
@@ -906,29 +906,7 @@ impl FirmwareUI for UIBolt {
Ok(layout)
}
- fn show_device_menu(
- _init_submenu_idx: Option<u8>,
- _init_submenu_offset: i16,
- _backup_failed: bool,
- _backup_needed: bool,
- _ble_enabled: bool,
- _paired_devices: heapless::Vec<
- (TString<'static>, Option<[TString<'static>; 2]>),
- MAX_PAIRED_DEVICES,
- >,
- _connected_idx: Option<u8>,
- _pin_enabled: Option<bool>,
- _auto_lock: Option<[TString<'static>; 2]>,
- _wipe_code_enabled: Option<bool>,
- _backup_check_allowed: bool,
- _device_name: Option<TString<'static>>,
- _brightness: Option<TString<'static>>,
- _tap_to_wake_enabled: Option<bool>,
- _haptics_enabled: Option<bool>,
- _led_enabled: Option<bool>,
- _about_items: Obj,
- _production_year: Option<TString<'static>>,
- ) -> Result<impl LayoutMaybeTrace, Error> {
+ fn show_device_menu(_params: DeviceMenuParams) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::NotImplementedError)
}
### core/embed/rust/src/ui/layout_caesar/ui_firmware.rs
@@ -30,8 +30,8 @@ use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ConfirmValueParams, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ DeviceMenuParams, FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES,
+ MAX_MENU_ITEMS, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::{geometry, ModelUI};
@@ -1104,29 +1104,7 @@ impl FirmwareUI for UICaesar {
Ok(layout)
}
- fn show_device_menu(
- _init_submenu_idx: Option<u8>,
- _init_submenu_offset: i16,
- _backup_failed: bool,
- _backup_needed: bool,
- _ble_enabled: bool,
- _paired_devices: heapless::Vec<
- (TString<'static>, Option<[TString<'static>; 2]>),
- MAX_PAIRED_DEVICES,
- >,
- _connected_idx: Option<u8>,
- _pin_enabled: Option<bool>,
- _auto_lock: Option<[TString<'static>; 2]>,
- _wipe_code_enabled: Option<bool>,
- _backup_check_allowed: bool,
- _device_name: Option<TString<'static>>,
- _brightness: Option<TString<'static>>,
- _tap_to_wake_enabled: Option<bool>,
- _haptics_enabled: Option<bool>,
- _led_enabled: Option<bool>,
- _about_items: Obj,
- _production_year: Option<TString<'static>>,
- ) -> Result<impl LayoutMaybeTrace, Error> {
+ fn show_device_menu(_params: DeviceMenuParams) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::NotImplementedError)
}
### core/embed/rust/src/ui/layout_delizia/ui_firmware.rs
@@ -38,8 +38,8 @@ use crate::ui::layout::obj::{LayoutMaybeTrace, LayoutObj, RootComponent};
use crate::ui::layout::util::{ContentType, PropsList, RecoveryType};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ DeviceMenuParams, FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES,
+ MAX_MENU_ITEMS, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
@@ -909,29 +909,7 @@ impl FirmwareUI for UIDelizia {
Ok(layout)
}
- fn show_device_menu(
- _init_submenu_idx: Option<u8>,
- _init_submenu_offset: i16,
- _backup_failed: bool,
- _backup_needed: bool,
- _ble_enabled: bool,
- _paired_devices: heapless::Vec<
- (TString<'static>, Option<[TString<'static>; 2]>),
- MAX_PAIRED_DEVICES,
- >,
- _connected_idx: Option<u8>,
- _pin_enabled: Option<bool>,
- _auto_lock: Option<[TString<'static>; 2]>,
- _wipe_code_enabled: Option<bool>,
- _backup_check_allowed: bool,
- _device_name: Option<TString<'static>>,
- _brightness: Option<TString<'static>>,
- _tap_to_wake_enabled: Option<bool>,
- _haptics_enabled: Option<bool>,
- _led_enabled: Option<bool>,
- _about_items: Obj,
- _production_year: Option<TString<'static>>,
- ) -> Result<impl LayoutMaybeTrace, Error> {
+ fn show_device_menu(_params: DeviceMenuParams) -> Result<impl LayoutMaybeTrace, Error> {
Err::<RootComponent<Empty, ModelUI>, Error>(Error::NotImplementedError)
}
### core/embed/rust/src/ui/layout_eckhart/firmware/device_menu_screen.rs
@@ -15,6 +15,7 @@ use crate::micropython::{Error, Obj};
use crate::strutil::TString;
use crate::translations::TR;
use crate::trezorhal::usb;
+use crate::ui::component::base::ParamsObj;
use crate::ui::component::text::paragraphs::{
Paragraph, ParagraphSource, ParagraphVecShort, Paragraphs, VecExt,
};
@@ -25,7 +26,7 @@ use crate::ui::geometry::{LinearPlacement, Rect};
pub use crate::ui::layout::device_menu_result::DeviceMenuMsg;
use crate::ui::layout::util::PropsList;
use crate::ui::shape::Renderer;
-use crate::ui::ui_firmware::MAX_PAIRED_DEVICES;
+use crate::ui::ui_firmware::{DeviceMenuParams, MAX_PAIRED_DEVICES};
#[cfg(feature = "ble")]
use crate::{trezorhal::ble, ui::event::BLEEvent};
@@ -87,13 +88,19 @@ impl DeviceMenuScreen {
DeviceMenuMsg::ToggleHaptics => DeviceMenuId::Device,
DeviceMenuMsg::ToggleLed => DeviceMenuId::Device,
DeviceMenuMsg::WipeDevice => DeviceMenuId::Device,
- DeviceMenuMsg::RefreshMenu => match self.active_screen.deref() {
- ActiveScreen::Menu(_, id) => *id,
- ActiveScreen::Device(_) => DeviceMenuId::PairAndConnect,
- ActiveScreen::Regulatory(_) | ActiveScreen::About(_) => DeviceMenuId::Device,
- ActiveScreen::Empty | ActiveScreen::BackupInfo(_) => DeviceMenuId::Root,
- ActiveScreen::HostInfo(_) => DeviceMenuId::PairAndConnect,
- },
+ }
+ }
+
+ /// The submenu the user is effectively in right now. For screens that are
+ /// not menus themselves (about, regulatory, ...), this is the submenu they
+ /// were reached from.
+ fn active_menu_id(&self) -> DeviceMenuId {
+ match self.active_screen.deref() {
+ ActiveScreen::Menu(_, id) => *id,
+ ActiveScreen::Device(_) => DeviceMenuId::PairAndConnect,
+ ActiveScreen::Regulatory(_) | ActiveScreen::About(_) => DeviceMenuId::Device,
+ ActiveScreen::Empty | ActiveScreen::BackupInfo(_) => DeviceMenuId::Root,
+ ActiveScreen::HostInfo(_) => DeviceMenuId::PairAndConnect,
}
}
}
@@ -274,27 +281,28 @@ pub struct DeviceMenuScreen {
}
impl DeviceMenuScreen {
- #[allow(clippy::too_many_arguments)]
- pub fn new(
- init_submenu_idx: Option<u8>,
- init_submenu_offset: i16,
- backup_failed: bool,
- backup_needed: bool,
- ble_enabled: bool,
- paired_devices: Vec<(TString<'static>, Option<[TString<'static>; 2]>), MAX_PAIRED_DEVICES>,
- connected_idx: Option<u8>,
- pin_enabled: Option<bool>,
- auto_lock: Option<[TString<'static>; 2]>,
- wipe_code_enabled: Option<bool>,
- backup_check_allowed: bool,
- device_name: Option<TString<'static>>,
- brightness: Option<TString<'static>>,
- tap_to_wake_enabled: Option<bool>,
- haptics_enabled: Option<bool>,
- led_enabled: Option<bool>,
- about_items: Obj,
- production_year: Option<TString<'static>>,
- ) -> Result<Self, Error> {
+ pub fn new(params: DeviceMenuParams) -> Result<Self, Error> {
+ let DeviceMenuParams {
+ init_submenu_idx,
+ init_submenu_offset,
+ backup_failed,
+ backup_needed,
+ ble_enabled,
+ paired_devices,
+ connected_idx,
+ pin_enabled,
+ auto_lock,
+ wipe_code_enabled,
+ backup_check_allowed,
+ device_name,
+ brightness,
+ tap_to_wake_enabled,
+ haptics_enabled,
+ led_enabled,
+ about_items,
+ production_year,
+ } = params;
+
let mut screen = Self {
bounds: Rect::zero(),
about_items,
@@ -785,6 +793,33 @@ impl DeviceMenuScreen {
}
}
+ /// Rebuild the whole menu from fresh parameters, without the layout being
+ /// restarted.
+ ///
+ /// Unlike at construction time, a missing `init_submenu_idx` means "stay
+ /// where the user is" rather than "open the root menu" -- a refresh should
+ /// not move the user around. Pass an explicit index to navigate.
+ fn update_params(&mut self, ctx: &mut EventCtx, params: ParamsObj) -> Result<(), Error> {
+ let mut params = DeviceMenuParams::try_from(params.obj())?;
+
+ if params.init_submenu_idx.is_none() {
+ params.init_submenu_idx = self.active_menu_id().to_u8();
+ params.init_submenu_offset = self.current_state().map_or(0, |(_, offset)| offset);
+ }
+
+ let bounds = self.bounds;
+ *self = Self::new(params)?;
+ self.place(bounds);
+ if let ActiveScreen::Menu(screen, ..) = self.active_screen.deref_mut() {
+ // Initialize now, so that a refresh of a running layout takes effect
+ // immediately without waiting for an event that may never come.
+ screen.initialize_screen(ctx);
+ }
+ ctx.request_repaint_root();
+
+ Ok(())
+ }
+
/// Used to avoid flickering on menu refresh.
pub fn current_state(&self) -> Option<(DeviceMenuId, i16)> {
match self.active_screen.deref() {
@@ -1028,16 +1063,28 @@ impl Component for DeviceMenuScreen {
}
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
- // Refresh this layout after reloading connection status
match event {
+ // The connection status we display went stale -- ask the application
+ // layer to hand us fresh parameters. The layout keeps running.
Event::USB(USBEvent::Configured | USBEvent::Deconfigured) => {
- return Some(DeviceMenuMsg::RefreshMenu)
+ ctx.request_params();
+ return None;
}
#[cfg(feature = "ble")]
Event::BLE(
BLEEvent::Connected | BLEEvent::Disconnected | BLEEvent::ConnectionChanged,
- ) => return Some(DeviceMenuMsg::RefreshMenu),
+ ) => {
+ ctx.request_params();
+ return None;
+ }
+
+ Event::UpdateParams(params) => {
+ // Malformed params mean the application layer and this screen
+ // disagree on the parameter set, which is a firmware bug.
+ unwrap!(self.update_params(ctx, params), "bad device menu params");
+ return None;
+ }
_ => (),
};
### core/embed/rust/src/ui/layout_eckhart/firmware/vertical_menu_screen.rs
@@ -96,8 +96,11 @@ impl<T: MenuItems> VerticalMenuScreen<T> {
/// Update swipe detection and buttons state based on menu size
pub fn initialize_screen(&mut self, ctx: &mut EventCtx) {
- // `self.initial_offset` replaced with 0, so next screens are not "resumed".
- let initial_offset = core::mem::take(&mut self.initial_offset);
+ // The position this screen sits at whenever it is initialized. Not
+ // consumed: a screen may be initialized more than once - refreshed in
+ // place and then attached again - and has to land in the same place
+ // every time.
+ let initial_offset = self.initial_offset;
if animation_disabled() {
self.swipe = Some(SwipeDetect::new());
### core/embed/rust/src/ui/layout_eckhart/ui_firmware.rs
@@ -40,8 +40,8 @@ use crate::ui::layout::util::{
};
use crate::ui::notification::Notification;
use crate::ui::ui_firmware::{
- FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES, MAX_MENU_ITEMS,
- MAX_PAIRED_DEVICES, MAX_WORD_QUIZ_ITEMS,
+ DeviceMenuParams, FirmwareUI, SelectMenuItem, MAX_CHECKLIST_ITEMS, MAX_GROUP_SHARE_LINES,
+ MAX_MENU_ITEMS, MAX_WORD_QUIZ_ITEMS,
};
use crate::ui::ModelUI;
use crate::util::interpolate;
@@ -1090,49 +1090,8 @@ impl FirmwareUI for UIEckhart {
Ok(layout)
}
- fn show_device_menu(
- init_submenu_idx: Option<u8>,
- init_submenu_offset: i16,
- backup_failed: bool,
- backup_needed: bool,
- ble_enabled: bool,
- paired_devices: heapless::Vec<
- (TString<'static>, Option<[TString<'static>; 2]>),
- MAX_PAIRED_DEVICES,
- >,
- connected_idx: Option<u8>,
- pin_enabled: Option<bool>,
- auto_lock: Option<[TString<'static>; 2]>,
- wipe_code_enabled: Option<bool>,
- backup_check_allowed: bool,
- device_name: Option<TString<'static>>,
- brightness: Option<TString<'static>>,
- tap_to_wake_enabled: Option<bool>,
- haptics_enabled: Option<bool>,
- led_enabled: Option<bool>,
- about_items: Obj,
- production_year: Option<TString<'static>>,
- ) -> Result<impl LayoutMaybeTrace, Error> {
- let layout = RootComponent::new(DeviceMenuScreen::new(
- init_submenu_idx,
- init_submenu_offset,
- backup_failed,
- backup_needed,
- ble_enabled,
- paired_devices,
- connected_idx,
- pin_enabled,
- auto_lock,
- wipe_code_enabled,
- backup_check_allowed,
- device_name,
- brightness,
- tap_to_wake_enabled,
- haptics_enabled,
- led_enabled,
- about_items,
- production_year,
- )?);
+ fn show_device_menu(params: DeviceMenuParams) -> Result<impl LayoutMaybeTrace, Error> {
+ let layout = RootComponent::new(DeviceMenuScreen::new(params)?);
Ok(layout)
}
### core/embed/rust/src/ui/ui_firmware.rs
@@ -5,9 +5,12 @@ use super::layout::obj::{LayoutMaybeTrace, LayoutObj};
use super::layout::util::RecoveryType;
use crate::io::BinaryData;
use crate::micropython::buffer::StrBuffer;
+use crate::micropython::dict::Dict;
use crate::micropython::gc::Gc;
+use crate::micropython::iter::IterBuf;
use crate::micropython::list::List;
-use crate::micropython::{Error, Obj};
+use crate::micropython::qstr::Qstr;
+use crate::micropython::{util, Error, Obj};
use crate::strutil::TString;
use crate::ui::notification::Notification;
@@ -36,6 +39,98 @@ impl SelectMenuItem {
}
}
+/// Everything `show_device_menu` needs to build (or rebuild) the device menu.
+///
+/// Bundled into a struct rather than passed as positional arguments so
+/// that the same set can be handed to a live layout again, via
+/// `LayoutObj.update_params`, without repeating the parsing.
+///
+/// A refresh is always complete: the caller sends every field and the menu is
+/// rebuilt from it. Partial updates are not supported, so no field can mean
+/// "leave this one alone".
+pub struct DeviceMenuParams {
+ pub init_submenu_idx: Option<u8>,
+ pub init_submenu_offset: i16,
+ pub backup_failed: bool,
+ pub backup_needed: bool,
+ pub ble_enabled: bool,
+ pub paired_devices: Vec<(TString<'static>, Option<[TString<'static>; 2]>), MAX_PAIRED_DEVICES>,
+ pub connected_idx: Option<u8>,
+ pub pin_enabled: Option<bool>,
+ pub auto_lock: Option<[TString<'static>; 2]>,
+ pub wipe_code_enabled: Option<bool>,
+ pub backup_check_allowed: bool,
+ pub device_name: Option<TString<'static>>,
+ pub brightness: Option<TString<'static>>,
+ pub tap_to_wake_enabled: Option<bool>,
+ pub haptics_enabled: Option<bool>,
+ pub led_enabled: Option<bool>,
+ pub about_items: Obj,
+ pub production_year: Option<TString<'static>>,
+}
+
+impl TryFrom<Obj> for DeviceMenuParams {
+ type Error = Error;
+
+ /// Parse the parameters out of the MicroPython `dict` the application layer
+ /// passes to `show_device_menu` and to `LayoutObj.update_params`.
+ fn try_from(params: Obj) -> Result<Self, Error> {
+ let dict: Gc<Dict> = params.try_into()?;
+ let kwargs = dict.map();
+
+ let paired_obj: Obj = kwargs.get(Qstr::MP_QSTR_paired_devices)?;
+ let mut paired_devices: Vec<(TString<'static>, Option<[TString; 2]>), MAX_PAIRED_DEVICES> =
+ Vec::new();
+ for device in IterBuf::new().try_iterate(paired_obj)? {
+ let [mac, host_info]: [Obj; 2] = util::iter_into_array(device)?;
+ let mac: TString<'static> = mac.try_into()?;
+ let host_info: Option<[TString<'static>; 2]> = host_info
+ .try_into_option()?
+ .map(util::iter_into_array)
+ .transpose()?;
+
+ if paired_devices.push((mac, host_info)).is_err() {
+ return Err(Error::OutOfRange);
+ }
+ }
+
+ Ok(Self {
+ init_submenu_idx: kwargs
+ .get(Qstr::MP_QSTR_init_submenu_idx)?
+ .try_into_option()?,
+ init_submenu_offset: kwargs.get(Qstr::MP_QSTR_init_submenu_offset)?.try_into()?,
+ backup_failed: kwargs.get(Qstr::MP_QSTR_backup_failed)?.try_into()?,
+ backup_needed: kwargs.get(Qstr::MP_QSTR_backup_needed)?.try_into()?,
+ ble_enabled: kwargs.get(Qstr::MP_QSTR_ble_enabled)?.try_into()?,
+ paired_devices,
+ connected_idx: kwargs.get(Qstr::MP_QSTR_connected_idx)?.try_into_option()?,
+ pin_enabled: kwargs.get(Qstr::MP_QSTR_pin_enabled)?.try_into_option()?,
+ auto_lock: kwargs
+ .get(Qstr::MP_QSTR_auto_lock)?
+ .try_into_option()?
+ .map(util::iter_into_array)
+ .transpose()?,
+ wipe_code_enabled: kwargs
+ .get(Qstr::MP_QSTR_wipe_code_enabled)?
+ .try_into_option()?,
+ backup_check_allowed: kwargs.get(Qstr::MP_QSTR_backup_check_allowed)?.try_into()?,
+ device_name: kwargs.get(Qstr::MP_QSTR_device_name)?.try_into_option()?,
+ brightness: kwargs.get(Qstr::MP_QSTR_brightness)?.try_into_option()?,
+ tap_to_wake_enabled: kwargs
+ .get(Qstr::MP_QSTR_tap_to_wake_enabled)?
+ .try_into_option()?,
+ haptics_enabled: kwargs
+ .get(Qstr::MP_QSTR_haptics_enabled)?
+ .try_into_option()?,
+ led_enabled: kwargs.get(Qstr::MP_QSTR_led_enabled)?.try_into_option()?,
+ about_items: kwargs.get(Qstr::MP_QSTR_about_items)?,
+ production_year: kwargs
+ .get(Qstr::MP_QSTR_production_year)?
+ .try_into_option()?,
+ })
+ }
+}
+
pub trait FirmwareUI {
#[allow(clippy::too_many_arguments)]
fn confirm_action(
@@ -352,30 +447,7 @@ pub trait FirmwareUI {
lockable: bool,
) -> Result<impl LayoutMaybeTrace, Error>;
- #[allow(clippy::too_many_arguments)]
- fn show_device_menu(
- init_submenu_idx: Option<u8>,
- init_submenu_offset: i16,
- backup_failed: bool,
- backup_needed: bool,
- ble_enabled: bool,
- paired_devices: heapless::Vec<
- (TString<'static>, Option<[TString<'static>; 2]>),
- MAX_PAIRED_DEVICES,
- >,
- connected_idx: Option<u8>,
- pin_enabled: Option<bool>,
- auto_lock: Option<[TString<'static>; 2]>,
- wipe_code_enabled: Option<bool>,
- backup_check_allowed: bool,
- device_name: Option<TString<'static>>,
- brightness: Option<TString<'static>>,
- tap_to_wake_enabled: Option<bool>,
- haptics_enabled: Option<bool>,
- led_enabled: Option<bool>,
- about_items: Obj,
- production_year: Option<TString<'static>>,
- ) -> Result<impl LayoutMaybeTrace, Error>;
+ fn show_device_menu(params: DeviceMenuParams) -> Result<impl LayoutMaybeTrace, Error>;
fn show_pairing_device_name(
description: StrBuffer,
### core/mocks/generated/trezorui_api.pyi
@@ -74,6 +74,16 @@ class LayoutObj(Generic[T]):
"""Return the number of pages in the layout object."""
def button_request(self) -> tuple[ButtonRequestType, str] | None:
"""Return (code, type) of button request made during the last event or timer pass."""
+ def needs_params_refresh(self) -> bool:
+ """Return True if the layout is waiting for fresh construction
+ parameters.
+ The request stays pending until `update_params()` serves it.
+ """
+ def update_params(self, params: Mapping[str, Any]) -> LayoutState | None:
+ """Hand fresh construction parameters to the layout.
+ `params` takes the same keys the layout was constructed with. The
+ layout updates itself in place, without being restarted.
+ """
def get_transition_out(self) -> AttachType:
"""Return the transition type."""
def return_value(self) -> T | None:
@@ -632,27 +642,38 @@ def show_homescreen(
"""Idle homescreen."""
+# rust/src/ui/api/firmware_micropython.rs
+class DeviceMenuParams(TypedDict):
+ """Everything the device menu is built from.
+ The same set opens the menu and refreshes a running one through
+ `LayoutObj.update_params`. A refresh always carries the complete set
+ and rebuilds the menu from it; there is no partial update or diff, so
+ every key is always present. A value of `None` therefore means "not
+ applicable on this device", never "unchanged".
+ """
+ init_submenu_idx: int | None
+ init_submenu_offset: int
+ backup_failed: bool
+ backup_needed: bool
+ ble_enabled: bool
+ paired_devices: Iterable[tuple[str, tuple[str, str] | None]]
+ connected_idx: int | None
+ pin_enabled: bool | None
+ auto_lock: tuple[str, str] | None
+ wipe_code_enabled: bool | None
+ backup_check_allowed: bool
+ device_name: str | None
+ brightness: str | None
+ tap_to_wake_enabled: bool | None
+ haptics_enabled: bool | None
+ led_enabled: bool | None
+ about_items: Sequence[tuple[str | None, StrOrBytes | None, bool | None]]
+ production_year: str | None
+
+
# rust/src/ui/api/firmware_micropython.rs
def show_device_menu(
- *,
- init_submenu_idx: int | None,
- init_submenu_offset: int,
- backup_failed: bool,
- backup_needed: bool,
- ble_enabled: bool,
- paired_devices: Iterable[tuple[str, tuple[str, str] | None]],
- connected_idx: int | None,
- pin_enabled: bool | None,
- auto_lock: tuple[str, str] | None,
- wipe_code_enabled: bool | None,
- backup_check_allowed: bool,
- device_name: str | None,
- brightness: str | None,
- tap_to_wake_enabled: bool | None,
- haptics_enabled: bool | None,
- led_enabled: bool | None,
- about_items: Sequence[tuple[str | None, StrOrBytes | None, bool | None]],
- production_year: str | None,
+ params: DeviceMenuParams,
) -> LayoutContext[tuple[str, int | None, int, int]]:
"""Show the device menu. Result is a tuple (action, action_arg, next_menu_id, next_menu_offset)."""
@@ -927,4 +948,3 @@ class DeviceMenuResult:
Reboot: ClassVar[str]
RebootToBootloader: ClassVar[str]
TurnOff: ClassVar[str]
- RefreshMenu: ClassVar[str]
### core/src/apps/homescreen/device_menu.py
@@ -14,6 +14,7 @@
from buffer_types import AnyBytes
from trezor.messages import ThpPairedCacheEntry
+ from trezorui_api import DeviceMenuParams
# Idicates that menu should be closed and return to homescreen.
@@ -67,126 +68,152 @@ def ble_enable(enable: bool) -> None:
storage_device.set_ble(enable)
-async def handle_device_menu() -> None:
+def _menu_params(
+ init_submenu_idx: int | None = None,
+ init_submenu_offset: int = 0,
+) -> "DeviceMenuParams":
+ """Collect everything the device menu is built from.
- assert utils.USE_THP and utils.USE_BLE
+ Called both to open the menu and to refresh it in place, so it must reflect
+ the current device state every time.
+ `init_submenu_idx` of None means "leave the menu where it is" on a refresh,
+ and "open at the root" when constructing.
+ """
from trezor.wire.thp import paired_cache
- init_submenu_idx = None
- init_submenu_offset = 0
-
- # Remain in the device loop until the menu is explicitly closed
- while True:
- is_initialized = storage_device.is_initialized()
- led_configurable = is_initialized and utils.USE_RGB_LED
- haptic_configurable = is_initialized and utils.USE_HAPTIC
- tap_to_wake_configurable = is_initialized and utils.USE_TOUCH_WAKEUP
- backup_failed = is_initialized and storage_device.unfinished_backup()
- backup_needed = is_initialized and storage_device.needs_backup()
- backup_finished = (
- is_initialized
- and not storage_device.needs_backup()
- and not storage_device.unfinished_backup()
- and not storage_device.no_backup()
+ is_initialized = storage_device.is_initialized()
+ led_configurable = is_initialized and utils.USE_RGB_LED
+ haptic_configurable = is_initialized and utils.USE_HAPTIC
+ tap_to_wake_configurable = is_initialized and utils.USE_TOUCH_WAKEUP
+ backup_failed = is_initialized and storage_device.unfinished_backup()
+ backup_needed = is_initialized and storage_device.needs_backup()
+ backup_finished = (
+ is_initialized
+ and not storage_device.needs_backup()
+ and not storage_device.unfinished_backup()
+ and not storage_device.no_backup()
+ )
+
+ bonds = ble.get_bonds()
+ if __debug__:
+ log.debug(__name__, "bonds: %s", bonds)
+ ble_enabled = ble.get_enabled()
+ connected_addr = ble.connected_addr()
+ connected_idx = _find_device(connected_addr, bonds) if ble_enabled else None
+ if __debug__:
+ log.debug(__name__, "connected: %s (%s)", connected_addr, connected_idx)
+ hostname_map = {e.mac_addr: e for e in paired_cache.load()}
+ paired_devices = [_get_hostinfo(bond, hostname_map) for bond in bonds]
+
+ # versions used in "About" screen, emulator uses dummy versions for fixtures
+ if utils.EMULATOR or not utils.USE_NRF:
+ bluetooth_version = "0.0.0.0"
+ else:
+ nrf_version = utils.nrf_get_version()
+ bluetooth_version = (
+ f"{nrf_version[0]}.{nrf_version[1]}.{nrf_version[2]}.{nrf_version[3]}"
)
+ if utils.EMULATOR:
+ firmware_version = "0.0.0.0"
+ else:
+ firmware_version = ".".join(map(str, utils.VERSION))
+
+ firmware_type = "Bitcoin-only" if utils.BITCOIN_ONLY else "Universal"
+ production_year = _get_production_year()
+
+ try:
+ serial_no = utils.serial_number() if utils.USE_SERIAL_NUMBER else None
+ except RuntimeError:
+ # Unprovisioned devices might not have a serial number
+ serial_no = "N/A"
+
+ about_items: list[tuple[str | None, str | None, bool]] = [
+ (TR.homescreen__firmware_version, firmware_version, False),
+ (TR.homescreen__firmware_type, firmware_type, False),
+ (TR.ble__version, bluetooth_version, False),
+ ]
+ if serial_no is not None:
+ about_items.append((TR.sn__title, serial_no, True))
+ about_items.append((TR.words__made_in, "Ostrava, Czechia", False))
+
+ return {
+ "init_submenu_idx": init_submenu_idx,
+ "init_submenu_offset": init_submenu_offset,
+ "backup_failed": backup_failed,
+ "backup_needed": backup_needed,
+ "ble_enabled": ble_enabled,
+ "paired_devices": paired_devices,
+ "connected_idx": connected_idx,
+ "pin_enabled": config.has_pin() if is_initialized else None,
+ "auto_lock": get_auto_lock_delay(),
+ "wipe_code_enabled": (
+ config.has_wipe_code() if (is_initialized and config.has_pin()) else None
+ ),
+ "backup_check_allowed": backup_finished,
+ "device_name": (
+ (storage_device.get_label() or utils.MODEL_FULL_NAME)
+ if is_initialized
+ else None
+ ),
+ "brightness": TR.brightness__title if is_initialized else None,
+ "tap_to_wake_enabled": (
+ storage_device.get_tap_to_wake() if tap_to_wake_configurable else None
+ ),
+ "haptics_enabled": (
+ storage_device.get_haptic_feedback() if haptic_configurable else None
+ ),
+ "led_enabled": (storage_device.get_rgb_led() if led_configurable else None),
+ "about_items": about_items,
+ "production_year": production_year,
+ }
+
+
+class DeviceMenuLayout(UsbAwareLayout):
+ """The device menu asks for fresh parameters when the connection changes.
+
+ No submenu index is supplied, so a refresh leaves the user where they are.
+ """
+
+ params_provider = staticmethod(_menu_params)
- bonds = ble.get_bonds()
- if __debug__:
- log.debug(__name__, "bonds: %s", bonds)
- ble_enabled = ble.get_enabled()
- connected_addr = ble.connected_addr()
- connected_idx = _find_device(connected_addr, bonds) if ble_enabled else None
- if __debug__:
- log.debug(__name__, "connected: %s (%s)", connected_addr, connected_idx)
- hostname_map = {e.mac_addr: e for e in paired_cache.load()}
- paired_devices = [_get_hostinfo(bond, hostname_map) for bond in bonds]
-
- # versions used in "About" screen, emulator uses dummy versions for fixtures
- if utils.EMULATOR or not utils.USE_NRF:
- bluetooth_version = "0.0.0.0"
- else:
- nrf_version = utils.nrf_get_version()
- bluetooth_version = (
- f"{nrf_version[0]}.{nrf_version[1]}.{nrf_version[2]}.{nrf_version[3]}"
- )
- if utils.EMULATOR:
- firmware_version = "0.0.0.0"
- else:
- firmware_version = ".".join(map(str, utils.VERSION))
-
- firmware_type = "Bitcoin-only" if utils.BITCOIN_ONLY else "Universal"
- production_year = _get_production_year()
-
- try:
- serial_no = utils.serial_number() if utils.USE_SERIAL_NUMBER else None
- except RuntimeError:
- # Unprovisioned devices might not have a serial number
- serial_no = "N/A"
-
- about_items: list[tuple[str | None, str | None, bool]] = [
- (TR.homescreen__firmware_version, firmware_version, False),
- (TR.homescreen__firmware_type, firmware_type, False),
- (TR.ble__version, bluetooth_version, False),
- ]
- if serial_no is not None:
- about_items.append((TR.sn__title, serial_no, True))
- about_items.append((TR.words__made_in, "Ostrava, Czechia", False))
-
- with trezorui_api.show_device_menu(
- init_submenu_idx=init_submenu_idx,
- init_submenu_offset=init_submenu_offset,
- backup_failed=backup_failed,
- backup_needed=backup_needed,
- ble_enabled=ble_enabled,
- paired_devices=paired_devices,
- connected_idx=connected_idx,
- pin_enabled=config.has_pin() if is_initialized else None,
- auto_lock=get_auto_lock_delay(),
- wipe_code_enabled=(
- config.has_wipe_code()
- if (is_initialized and config.has_pin())
- else None
- ),
- backup_check_allowed=backup_finished,
- device_name=(
- (storage_device.get_label() or utils.MODEL_FULL_NAME)
- if is_initialized
- else None
- ),
- brightness=TR.brightness__title if is_initialized else None,
- tap_to_wake_enabled=(
- storage_device.get_tap_to_wake() if tap_to_wake_configurable else None
- ),
- haptics_enabled=(
- storage_device.get_haptic_feedback() if haptic_configurable else None
- ),
- led_enabled=(storage_device.get_rgb_led() if led_configurable else None),
- about_items=about_items,
- production_year=production_year,
- ) as layout:
+
+async def handle_device_menu() -> None:
+
+ assert utils.USE_THP and utils.USE_BLE
+
+ # Remain in the device loop until the menu is explicitly closed. The layout is
+ # built once and refreshed in place; it is never rebuilt from scratch.
+ with trezorui_api.show_device_menu(_menu_params()) as layout:
+ while True:
menu_result = await interact(
- layout, br_name=None, layout_type=UsbAwareLayout
+ layout,
+ br_name=None,
+ layout_type=DeviceMenuLayout,
)
- if not isinstance(menu_result, tuple) or len(menu_result) != 4:
- raise RuntimeError(f"Unknown menu {menu_result}")
+ if not isinstance(menu_result, tuple) or len(menu_result) != 4:
+ raise RuntimeError(f"Unknown menu {menu_result}")
+
+ action, arg, next_submenu_idx, next_submenu_offset = menu_result
+ handler = _MENU_HANDLERS.get(action)
+ if not handler:
+ raise RuntimeError(f"Unknown menu {menu_result}")
- action, arg, init_submenu_idx, init_submenu_offset = menu_result
- handler = _MENU_HANDLERS.get(action)
- if not handler:
- raise RuntimeError(f"Unknown menu {menu_result}")
+ try:
+ if arg is None:
+ await handler()
+ else:
+ await handler(arg)
+ except ExitDeviceMenu:
+ break
+ except (ActionCancelled, PinCancelled):
+ # return to the submenu if handler was cancelled / succeeded
+ pass
- try:
- if arg is None:
- await handler()
- else:
- await handler(arg)
- except ExitDeviceMenu:
- break
- except (ActionCancelled, PinCancelled):
- # return to the submenu if handler was cancelled / succeeded
- continue
+ # The handler may have changed what the menu displays. Refresh it
+ # before it is shown again, so it never paints stale contents.
+ layout.update_params(_menu_params(next_submenu_idx, next_submenu_offset))
async def handle_Close() -> None:
@@ -487,10 +514,6 @@ async def handle_RebootToBootloader() -> None:
raise RuntimeError
-async def handle_RefreshMenu() -> None:
- pass
-
-
_MENU_HANDLERS = {
DeviceMenuResult.Close: handle_Close,
DeviceMenuResult.ReviewFailedBackup: handle_ReviewFailedBackup,
@@ -515,5 +538,4 @@ async def handle_RefreshMenu() -> None:
DeviceMenuResult.TurnOff: handle_TurnOff,
DeviceMenuResult.Reboot: handle_Reboot,
DeviceMenuResult.RebootToBootloader: handle_RebootToBootloader,
- DeviceMenuResult.RefreshMenu: handle_RefreshMenu,
}
### core/src/trezor/ui/__init__.py
@@ -20,7 +20,7 @@
from trezor.power_management.autodim import autodim_clear
if TYPE_CHECKING:
- from collections.abc import Callable, Generator, Iterator
+ from collections.abc import Callable, Generator, Iterator, Mapping
from typing import Any, Generic, TypeVar
from trezor.enums import ButtonRequestType
@@ -121,6 +121,11 @@ class Layout(Generic[T]):
[docs/core/misc/layout-lifecycle.md] for details.
"""
+ # Supplies fresh construction parameters when the Rust layout asks for them.
+ # Subclasses of layouts that call `EventCtx::request_params()` override this
+ # with a `staticmethod`; everything else leaves it as None.
+ params_provider: "Callable[[], Mapping[str, Any]] | None" = None
+
if __debug__:
@staticmethod
@@ -304,6 +309,10 @@ def _event(self, event_call: Callable[..., LayoutState | None], *args: Any) -> N
first_paint = False
state = event_call(*args)
+ if state is None:
+ # The layout may have asked for fresh parameters instead of finishing.
+ # Feed them in right away, so it can update itself in place.
+ state = self._refresh_params()
self.transition_out = self.layout.get_transition_out()
if state is LayoutState.DONE:
@@ -326,6 +335,21 @@ def _event(self, event_call: Callable[..., LayoutState | None], *args: Any) -> N
else:
self._paint()
+ def _refresh_params(self) -> LayoutState | None:
+ """Hand the layout fresh construction parameters, if it asked for them.
+
+ Returns the state of the resulting update pass, or None if no refresh
+ was requested. Lets a layout react to changed inputs without being torn
+ down and redrawn from scratch.
+ """
+ if not self.layout.needs_params_refresh():
+ return None
+ if self.params_provider is None:
+ # The layout is waiting for parameters nobody can supply, and the
+ # request stays pending, so it would ask again on every event pass.
+ raise wire.FirmwareError("layout asked for params but none are provided")
+ return self.layout.update_params(self.params_provider())
+
def put_button_request(self, msg: ButtonRequestMsg | None) -> bool:
if self.button_request_handler is None or msg is None:
return FalseWhy this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.