bitbox03/ui: split UI from async machine
What changed, and why it matters
This commit is a routine internal code cleanup for the BitBox02/BitBox03 firmware's user interface. It moves the confirmation dialog code into its own file and introduces a small reusable helper for handling button-press results in async Rust code. There is no indication this fixes a security bug or changes user-visible security behavior.
No security action required; treat as normal refactoring review.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change refactors bitbox03/src/ui.rs by extracting the confirm-screen builder into a new ui/confirm.rs module and adding a generic util::futures::completion pair (UiResponder/UiResult) to replace the inline Rc<RefCell<SharedState>> + poll_fn pattern. The new helper preserves the same semantics: first resolution wins, waker is notified, and the result is polled exactly once. The diff shows no functional change to security checks, no new dependencies beyond the internal util crate, and no bug fixes.
Changed components
src/rust/bitbox03/src/ui.rssrc/rust/bitbox03/src/ui/confirm.rssrc/rust/util/src/futures/completion.rsInspect captured patch +265 / −128
diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock
index 60a6b0a..338a954 100644
--- a/src/rust/Cargo.lock
+++ b/src/rust/Cargo.lock
@@ -313,6 +313,7 @@ dependencies = [
"bitbox-lvgl",
"png-decoder",
"tracing",
+ "util",
"zeroize",
]
diff --git a/src/rust/bitbox03/Cargo.toml b/src/rust/bitbox03/Cargo.toml
index b689e1c..738030e 100644
--- a/src/rust/bitbox03/Cargo.toml
+++ b/src/rust/bitbox03/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies]
bitbox-lvgl = { path = "../bitbox-lvgl" }
bitbox-hal = { path = "../bitbox-hal" }
+util = { path = "../util" }
tracing = { version = "0.1.41", features = ["log"], default-features = false }
png-decoder = { version="0.2"}
zeroize = {workspace=true}
diff --git a/src/rust/bitbox03/src/ui.rs b/src/rust/bitbox03/src/ui.rs
index dd0d717..24a653f 100644
--- a/src/rust/bitbox03/src/ui.rs
+++ b/src/rust/bitbox03/src/ui.rs
@@ -1,13 +1,13 @@
-use alloc::rc::Rc;
use alloc::vec::Vec;
use bitbox_hal as hal;
use bitbox_lvgl::{
- self as lvgl, LabelExt, LvAlign, LvButton, LvDisplay, LvHandle, LvLabel, LvLabelLongMode,
- LvObj, LvOpacityLevel, LvPart, LvSpangroup, ObjExt, SpangroupExt,
+ self as lvgl, LabelExt, LvAlign, LvDisplay, LvHandle, LvLabel, LvObj, LvOpacityLevel, LvPart,
+ LvSpangroup, ObjExt, SpangroupExt,
};
-use core::cell::RefCell;
-use core::task::{Poll, Waker};
use tracing::info;
+use util::futures::completion;
+
+mod confirm;
const LOGO: &[u8] = include_bytes!("../splash.png");
@@ -47,129 +47,8 @@ impl hal::ui::Ui for BitBox03Ui {
&mut self,
params: &bitbox_hal::ui::ConfirmParams<'_>,
) -> Result<(), bitbox_hal::ui::UserAbort> {
- struct SharedState {
- waker: Option<Waker>,
- result: Option<Result<(), hal::ui::UserAbort>>,
- }
-
- let shared_state = Rc::new(RefCell::new(SharedState {
- waker: None,
- result: None,
- }));
-
- let screen = LvObj::new().unwrap();
- screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
- screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
- screen.set_style_bg_color(lvgl::color::black(), 0);
- screen.set_style_text_color(lvgl::color::white(), 0);
- screen.set_style_pad_top(40, 0);
- screen.set_style_pad_right(50, 0);
- screen.set_style_pad_bottom(40, 0);
- screen.set_style_pad_left(50, 0);
- screen.set_style_pad_row(24, 0);
-
- let title = LvLabel::new(&screen).unwrap();
- title.set_width(380);
- title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
- title.set_text(params.title).unwrap();
- title.set_style_text_font(
- lvgl::fonts::INTER_BOLD_48,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
-
- let body = LvLabel::new(&screen).unwrap();
- body.set_width(380);
- body.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
- body.set_text(params.body).unwrap();
- body.set_style_text_font(
- lvgl::fonts::INTER_REGULAR_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- body.set_style_flex_grow(1, 0);
-
- let actions = LvObj::with_parent(&screen).unwrap();
- actions.set_width(380);
- actions.set_height(72);
- actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
- actions.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
- actions.set_style_pad_top(0, 0);
- actions.set_style_pad_bottom(0, 0);
- actions.set_style_pad_left(0, 0);
- actions.set_style_pad_right(0, 0);
- actions.set_style_pad_column(20, 0);
- actions.set_style_margin_top(16, 0);
- actions.set_style_border_width(0, 0);
- actions.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
-
- let reject = LvButton::new(&actions).unwrap();
- reject.set_size(180, 72);
- reject.set_style_bg_color(lvgl::color::hex(0x30333a), 0);
- reject.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- reject.set_style_border_width(2, 0);
- reject.set_style_border_color(lvgl::color::white(), 0);
- let reject_state = Rc::clone(&shared_state);
- reject
- .add_click_cb(move || {
- let mut shared_state = reject_state.borrow_mut();
- if shared_state.result.is_none() {
- shared_state.result = Some(Err(hal::ui::UserAbort));
- if let Some(waker) = shared_state.waker.as_ref() {
- waker.wake_by_ref();
- }
- }
- })
- .expect("failed to register reject callback");
- let reject_label = LvLabel::new(&reject).unwrap();
- reject_label.set_text("No").unwrap();
- reject_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- reject_label.set_style_text_color(lvgl::color::white(), 0);
- reject_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
-
- let accept = LvButton::new(&actions).unwrap();
- accept.set_size(180, 72);
- accept.set_style_bg_color(lvgl::color::white(), 0);
- accept.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
- accept.set_style_border_width(2, 0);
- accept.set_style_border_color(lvgl::color::black(), 0);
- let accept_state = Rc::clone(&shared_state);
- accept
- .add_click_cb(move || {
- let mut shared_state = accept_state.borrow_mut();
- if shared_state.result.is_none() {
- shared_state.result = Some(Ok(()));
- if let Some(waker) = shared_state.waker.as_ref() {
- waker.wake_by_ref();
- }
- }
- })
- .expect("failed to register accept callback");
- let accept_label = LvLabel::new(&accept).unwrap();
- accept_label.set_text("Yes").unwrap();
- accept_label.set_style_text_font(
- lvgl::fonts::INTER_BOLD_32,
- lvgl::LvState::LV_STATE_DEFAULT as u32,
- );
- accept_label.set_style_text_color(lvgl::color::black(), 0);
- accept_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
-
- let _screen = self.push_guard(screen);
-
- core::future::poll_fn({
- let shared_state = &shared_state;
- move |cx| {
- let mut shared_state = shared_state.borrow_mut();
- if let Some(result) = shared_state.result.take() {
- Poll::Ready(result)
- } else {
- shared_state.waker = Some(cx.waker().clone());
- Poll::Pending
- }
- }
- })
- .await
+ self.with_result_screen(|responder| confirm::build_confirm_screen(params, responder))
+ .await
}
async fn verify_recipient(
@@ -368,6 +247,16 @@ impl BitBox03Ui {
ScreenGuard { ui: self }
}
+ async fn with_result_screen<T, F>(&mut self, build_screen: F) -> T
+ where
+ F: FnOnce(completion::UiResponder<T>) -> LvObj,
+ {
+ let (responder, result) = completion::completion();
+ let screen = build_screen(responder);
+ let _screen = self.push_guard(screen);
+ result.await
+ }
+
pub fn push(&mut self, screen: LvObj) {
if let Some(display) = &self.display {
let current = display.screen_active().expect("No active screen?!");
diff --git a/src/rust/bitbox03/src/ui/confirm.rs b/src/rust/bitbox03/src/ui/confirm.rs
new file mode 100644
index 0000000..ec29ae9
--- /dev/null
+++ b/src/rust/bitbox03/src/ui/confirm.rs
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use bitbox_hal::ui::{ConfirmParams, UserAbort};
+use bitbox_lvgl::{
+ self as lvgl, LabelExt, LvAlign, LvButton, LvLabel, LvLabelLongMode, LvObj, LvOpacityLevel,
+ ObjExt,
+};
+use util::futures::completion::UiResponder;
+
+pub(super) fn build_confirm_screen(
+ params: &ConfirmParams<'_>,
+ responder: UiResponder<Result<(), UserAbort>>,
+) -> LvObj {
+ let screen = LvObj::new().unwrap();
+ screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
+ screen.set_style_bg_color(lvgl::color::black(), 0);
+ screen.set_style_text_color(lvgl::color::white(), 0);
+ screen.set_style_pad_top(40, 0);
+ screen.set_style_pad_right(50, 0);
+ screen.set_style_pad_bottom(40, 0);
+ screen.set_style_pad_left(50, 0);
+ screen.set_style_pad_row(24, 0);
+
+ let title = LvLabel::new(&screen).unwrap();
+ title.set_width(380);
+ title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ title.set_text(params.title).unwrap();
+ title.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+
+ let body = LvLabel::new(&screen).unwrap();
+ body.set_width(380);
+ body.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ body.set_text(params.body).unwrap();
+ body.set_style_text_font(
+ lvgl::fonts::INTER_REGULAR_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ body.set_style_flex_grow(1, 0);
+
+ let actions = LvObj::with_parent(&screen).unwrap();
+ actions.set_width(380);
+ actions.set_height(72);
+ actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ actions.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ actions.set_style_pad_top(0, 0);
+ actions.set_style_pad_bottom(0, 0);
+ actions.set_style_pad_left(0, 0);
+ actions.set_style_pad_right(0, 0);
+ actions.set_style_pad_column(20, 0);
+ actions.set_style_margin_top(16, 0);
+ actions.set_style_border_width(0, 0);
+ actions.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+
+ let reject = LvButton::new(&actions).unwrap();
+ reject.set_size(180, 72);
+ reject.set_style_bg_color(lvgl::color::hex(0x30333a), 0);
+ reject.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ reject.set_style_border_width(2, 0);
+ reject.set_style_border_color(lvgl::color::white(), 0);
+ let reject_responder = responder.clone();
+ reject
+ .add_click_cb(move || reject_responder.resolve(Err(UserAbort)))
+ .expect("failed to register reject callback");
+ let reject_label = LvLabel::new(&reject).unwrap();
+ reject_label.set_text("No").unwrap();
+ reject_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ reject_label.set_style_text_color(lvgl::color::white(), 0);
+ reject_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+
+ let accept = LvButton::new(&actions).unwrap();
+ accept.set_size(180, 72);
+ accept.set_style_bg_color(lvgl::color::white(), 0);
+ accept.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ accept.set_style_border_width(2, 0);
+ accept.set_style_border_color(lvgl::color::black(), 0);
+ accept
+ .add_click_cb(move || responder.resolve(Ok(())))
+ .expect("failed to register accept callback");
+ let accept_label = LvLabel::new(&accept).unwrap();
+ accept_label.set_text("Yes").unwrap();
+ accept_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ accept_label.set_style_text_color(lvgl::color::black(), 0);
+ accept_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+
+ screen
+}
diff --git a/src/rust/util/src/futures.rs b/src/rust/util/src/futures.rs
new file mode 100644
index 0000000..adf7b09
--- /dev/null
+++ b/src/rust/util/src/futures.rs
@@ -0,0 +1,3 @@
+// SPDX-License-Identifier: Apache-2.0
+
+pub mod completion;
diff --git a/src/rust/util/src/futures/completion.rs b/src/rust/util/src/futures/completion.rs
new file mode 100644
index 0000000..b136796
--- /dev/null
+++ b/src/rust/util/src/futures/completion.rs
@@ -0,0 +1,145 @@
+// SPDX-License-Identifier: Apache-2.0
+
+use alloc::rc::Rc;
+use core::cell::RefCell;
+use core::future::Future;
+use core::pin::Pin;
+use core::task::{Context, Poll, Waker};
+
+struct SharedState<T> {
+ waker: Option<Waker>,
+ result: Option<T>,
+}
+
+pub struct UiResponder<T> {
+ shared_state: Rc<RefCell<SharedState<T>>>,
+}
+
+pub struct UiResult<T> {
+ shared_state: Rc<RefCell<SharedState<T>>>,
+}
+
+pub fn completion<T>() -> (UiResponder<T>, UiResult<T>) {
+ let shared_state = Rc::new(RefCell::new(SharedState {
+ waker: None,
+ result: None,
+ }));
+ (
+ UiResponder {
+ shared_state: Rc::clone(&shared_state),
+ },
+ UiResult { shared_state },
+ )
+}
+
+impl<T> UiResponder<T> {
+ pub fn resolve(&self, value: T) {
+ let mut shared_state = self.shared_state.borrow_mut();
+ if shared_state.result.is_none() {
+ shared_state.result = Some(value);
+ if let Some(waker) = shared_state.waker.as_ref() {
+ waker.wake_by_ref();
+ }
+ }
+ }
+}
+
+impl<T> Clone for UiResponder<T> {
+ fn clone(&self) -> Self {
+ Self {
+ shared_state: Rc::clone(&self.shared_state),
+ }
+ }
+}
+
+impl<T> Future for UiResult<T> {
+ type Output = T;
+
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
+ let mut shared_state = self.shared_state.borrow_mut();
+ if let Some(result) = shared_state.result.take() {
+ Poll::Ready(result)
+ } else {
+ shared_state.waker = Some(cx.waker().clone());
+ Poll::Pending
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ extern crate std;
+
+ use super::*;
+ use core::pin::pin;
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::task::Wake;
+
+ struct CountingWake {
+ wake_count: AtomicUsize,
+ }
+
+ impl CountingWake {
+ fn new() -> Self {
+ Self {
+ wake_count: AtomicUsize::new(0),
+ }
+ }
+
+ fn wake_count(&self) -> usize {
+ self.wake_count.load(Ordering::SeqCst)
+ }
+ }
+
+ impl Wake for CountingWake {
+ fn wake(self: Arc<Self>) {
+ self.wake_count.fetch_add(1, Ordering::SeqCst);
+ }
+
+ fn wake_by_ref(self: &Arc<Self>) {
+ self.wake_count.fetch_add(1, Ordering::SeqCst);
+ }
+ }
+
+ #[test]
+ fn test_completion_resolve_before_poll() {
+ let (responder, result) = completion();
+ responder.resolve(42);
+
+ let waker = std::task::Waker::from(Arc::new(CountingWake::new()));
+ let mut cx = Context::from_waker(&waker);
+ let mut result = pin!(result);
+
+ assert!(matches!(result.as_mut().poll(&mut cx), Poll::Ready(42)));
+ }
+
+ #[test]
+ fn test_completion_resolve_wakes() {
+ let (responder, result) = completion();
+ let wake = Arc::new(CountingWake::new());
+ let waker = std::task::Waker::from(Arc::clone(&wake));
+ let mut cx = Context::from_waker(&waker);
+ let mut result = pin!(result);
+
+ assert!(matches!(result.as_mut().poll(&mut cx), Poll::Pending));
+ assert_eq!(wake.wake_count(), 0);
+
+ responder.resolve(42);
+ assert_eq!(wake.wake_count(), 1);
+ assert!(matches!(result.as_mut().poll(&mut cx), Poll::Ready(42)));
+ }
+
+ #[test]
+ fn test_completion_first_resolution_wins() {
+ let (responder, result) = completion();
+ responder.resolve(42);
+ responder.resolve(99);
+
+ let waker = std::task::Waker::from(Arc::new(CountingWake::new()));
+ let mut cx = Context::from_waker(&waker);
+ let mut result = pin!(result);
+
+ assert!(matches!(result.as_mut().poll(&mut cx), Poll::Ready(42)));
+ }
+}
diff --git a/src/rust/util/src/lib.rs b/src/rust/util/src/lib.rs
index 4faa048..2ba2a6d 100644
--- a/src/rust/util/src/lib.rs
+++ b/src/rust/util/src/lib.rs
@@ -8,6 +8,7 @@ pub mod bytes;
pub mod cell;
pub mod datetime;
pub mod decimal;
+pub mod futures;
pub mod log;
pub mod name;
pub mod strings;
diff --git a/test/simulator-graphical-bb03/Cargo.lock b/test/simulator-graphical-bb03/Cargo.lock
index 1e9ec6b..f4805a0 100644
--- a/test/simulator-graphical-bb03/Cargo.lock
+++ b/test/simulator-graphical-bb03/Cargo.lock
@@ -478,6 +478,7 @@ dependencies = [
"bitbox-lvgl",
"png-decoder",
"tracing",
+ "util",
"zeroize",
]
Why this scored 15/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.