From 18a9c9cb1c6fdfa7c4f5c24574885e021d302b2d Mon Sep 17 00:00:00 2001 From: obrusvit Date: Tue, 16 Apr 2024 15:40:01 +0200 Subject: [PATCH] feat(core/ui): T3T1 reset wallet screen The first screen in wallet reset (onboarding or recovery) implemented as a simple SwipeFlow to handle moving between Menu and back. --- core/embed/rust/librust_qstr.h | 1 + .../generated/translated_string.rs | 3 + .../src/ui/model_mercury/component/footer.rs | 16 ++- .../src/ui/model_mercury/component/frame.rs | 2 +- .../ui/model_mercury/component/share_words.rs | 4 +- .../model_mercury/component/vertical_menu.rs | 11 +- .../flow/confirm_reset_device.rs | 126 ++++++++++++++++++ .../src/ui/model_mercury/flow/get_address.rs | 5 +- .../rust/src/ui/model_mercury/flow/mod.rs | 2 + .../embed/rust/src/ui/model_mercury/layout.rs | 29 +--- .../rust/src/ui/model_mercury/theme/mod.rs | 8 +- core/mocks/trezortranslate_keys.pyi | 1 + .../src/trezor/ui/layouts/mercury/__init__.py | 9 +- core/translations/cs.json | 1 + core/translations/de.json | 1 + core/translations/en.json | 1 + core/translations/es.json | 1 + core/translations/fr.json | 1 + core/translations/order.json | 3 +- core/translations/signatures.json | 6 +- 20 files changed, 180 insertions(+), 51 deletions(-) create mode 100644 core/embed/rust/src/ui/model_mercury/flow/confirm_reset_device.rs diff --git a/core/embed/rust/librust_qstr.h b/core/embed/rust/librust_qstr.h index c4637ce96..625ecc193 100644 --- a/core/embed/rust/librust_qstr.h +++ b/core/embed/rust/librust_qstr.h @@ -231,6 +231,7 @@ static void _librust_qstrs(void) { MP_QSTR_inputs__return; MP_QSTR_inputs__show; MP_QSTR_inputs__space; + MP_QSTR_instructions__swipe_up; MP_QSTR_is_type_of; MP_QSTR_items; MP_QSTR_joint__title; diff --git a/core/embed/rust/src/translations/generated/translated_string.rs b/core/embed/rust/src/translations/generated/translated_string.rs index 337b403c7..fab0fb260 100644 --- a/core/embed/rust/src/translations/generated/translated_string.rs +++ b/core/embed/rust/src/translations/generated/translated_string.rs @@ -1237,6 +1237,7 @@ pub enum TranslatedString { storage_msg__starting = 842, // "STARTING UP" storage_msg__verifying_pin = 843, // "VERIFYING PIN" storage_msg__wrong_pin = 844, // "WRONG PIN" + instructions__swipe_up = 845, // "Swipe up" } impl TranslatedString { @@ -2469,6 +2470,7 @@ impl TranslatedString { Self::storage_msg__starting => "STARTING UP", Self::storage_msg__verifying_pin => "VERIFYING PIN", Self::storage_msg__wrong_pin => "WRONG PIN", + Self::instructions__swipe_up => "Swipe up", } } @@ -3702,6 +3704,7 @@ impl TranslatedString { Qstr::MP_QSTR_storage_msg__starting => Some(Self::storage_msg__starting), Qstr::MP_QSTR_storage_msg__verifying_pin => Some(Self::storage_msg__verifying_pin), Qstr::MP_QSTR_storage_msg__wrong_pin => Some(Self::storage_msg__wrong_pin), + Qstr::MP_QSTR_instructions__swipe_up => Some(Self::instructions__swipe_up), _ => None, } } diff --git a/core/embed/rust/src/ui/model_mercury/component/footer.rs b/core/embed/rust/src/ui/model_mercury/component/footer.rs index 91038a1cd..4161b2ba8 100644 --- a/core/embed/rust/src/ui/model_mercury/component/footer.rs +++ b/core/embed/rust/src/ui/model_mercury/component/footer.rs @@ -14,6 +14,7 @@ use crate::{ /// height must be 18px (only instruction) or 37px (both description and /// instruction). The content and style of both description and instruction is /// configurable separatedly. +#[derive(Clone)] pub struct Footer<'a> { area: Rect, text_instruction: TString<'a>, @@ -33,8 +34,8 @@ impl<'a> Footer<'a> { area: Rect::zero(), text_instruction: instruction.into(), text_description: None, - style_instruction: &theme::TEXT_SUB, - style_description: &theme::TEXT_SUB, + style_instruction: &theme::TEXT_SUB_GREY, + style_description: &theme::TEXT_SUB_GREY_LIGHT, } } @@ -138,3 +139,14 @@ impl<'a> Component for Footer<'a> { sink(self.area); } } + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for Footer<'_> { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.component("Footer"); + if let Some(description) = self.text_description { + t.string("description", description); + } + t.string("instruction", self.text_instruction); + } +} diff --git a/core/embed/rust/src/ui/model_mercury/component/frame.rs b/core/embed/rust/src/ui/model_mercury/component/frame.rs index 16a6f0dd7..fae6c4b0a 100644 --- a/core/embed/rust/src/ui/model_mercury/component/frame.rs +++ b/core/embed/rust/src/ui/model_mercury/component/frame.rs @@ -66,7 +66,7 @@ where } pub fn with_subtitle(mut self, subtitle: TString<'static>) -> Self { - let style = theme::TEXT_SUB; + let style = theme::TEXT_SUB_GREY_LIGHT; self.title = Child::new(self.title.into_inner().top_aligned()); self.subtitle = Some(Child::new(Label::new( subtitle, diff --git a/core/embed/rust/src/ui/model_mercury/component/share_words.rs b/core/embed/rust/src/ui/model_mercury/component/share_words.rs index 5d3cfb9dc..564d3173d 100644 --- a/core/embed/rust/src/ui/model_mercury/component/share_words.rs +++ b/core/embed/rust/src/ui/model_mercury/component/share_words.rs @@ -105,10 +105,10 @@ impl<'a> Component for ShareWords<'a> { // the ordinal number of the current word let ordinal_val = self.page_index as u8 + 1; let ordinal_pos = self.area_word.top_left() - + Offset::y(theme::TEXT_SUB.text_font.visible_text_height("1")); + + Offset::y(theme::TEXT_SUB_GREY_LIGHT.text_font.visible_text_height("1")); let ordinal = build_string!(3, inttostr!(ordinal_val), "."); shape::Text::new(ordinal_pos, &ordinal) - .with_font(theme::TEXT_SUB.text_font) + .with_font(theme::TEXT_SUB_GREY_LIGHT.text_font) .with_fg(theme::GREY) .render(target); diff --git a/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs b/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs index 84b89b236..13edb4381 100644 --- a/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs +++ b/core/embed/rust/src/ui/model_mercury/component/vertical_menu.rs @@ -59,13 +59,13 @@ impl VerticalMenu { Self::new(buttons_vec) } - pub fn context_menu(options: [(&'static str, Icon); 3]) -> Self { - // TODO: this is just POC + pub fn context_menu(options: Vec<(&'static str, Icon), N_ITEMS>) -> Self { // FIXME: args should be TString when IconText has TString let mut buttons_vec = VerticalMenuButtons::new(); for opt in options { let button_theme; match opt.1 { + // FIXME: might not be applicable everywhere theme::ICON_CANCEL => { button_theme = theme::button_vertical_menu_orange(); } @@ -93,11 +93,12 @@ impl Component for VerticalMenu { self.area = bounds; self.areas_sep.clear(); let mut remaining = bounds; - for i in 0..N_ITEMS { + let n_seps = self.buttons.len() - 1; + for (i, button) in self.buttons.iter_mut().enumerate() { let (area_button, new_remaining) = remaining.split_top(MENU_BUTTON_HEIGHT); - self.buttons[i].place(area_button); + button.place(area_button); remaining = new_remaining; - if i < N_SEPS { + if i < n_seps { let (area_sep, new_remaining) = remaining.split_top(MENU_SEP_HEIGHT); unwrap!(self.areas_sep.push(area_sep)); remaining = new_remaining; diff --git a/core/embed/rust/src/ui/model_mercury/flow/confirm_reset_device.rs b/core/embed/rust/src/ui/model_mercury/flow/confirm_reset_device.rs new file mode 100644 index 000000000..32475935a --- /dev/null +++ b/core/embed/rust/src/ui/model_mercury/flow/confirm_reset_device.rs @@ -0,0 +1,126 @@ +use crate::{ + error, + micropython::qstr::Qstr, + strutil::TString, + translations::TR, + ui::{ + component::text::paragraphs::{Paragraph, Paragraphs}, + flow::{ + base::Decision, flow_store, FlowMsg, FlowState, FlowStore, SwipeDirection, SwipeFlow, + SwipePage, + }, + }, +}; +use heapless::Vec; + +use super::super::{ + component::{Frame, FrameMsg, VerticalMenu, VerticalMenuChoiceMsg}, + theme, +}; + +#[derive(Copy, Clone, PartialEq, Eq, ToPrimitive)] +pub enum ConfirmResetDevice { + Intro, + Menu, +} + +impl FlowState for ConfirmResetDevice { + fn handle_swipe(&self, direction: SwipeDirection) -> Decision { + match (self, direction) { + (ConfirmResetDevice::Intro, SwipeDirection::Left) => { + Decision::Goto(ConfirmResetDevice::Menu, direction) + } + (ConfirmResetDevice::Menu, SwipeDirection::Right) => { + Decision::Goto(ConfirmResetDevice::Intro, direction) + } + (ConfirmResetDevice::Intro, SwipeDirection::Up) => Decision::Return(FlowMsg::Confirmed), + _ => Decision::Nothing, + } + } + + fn handle_event(&self, msg: FlowMsg) -> Decision { + match (self, msg) { + (ConfirmResetDevice::Intro, FlowMsg::Info) => { + Decision::Goto(ConfirmResetDevice::Menu, SwipeDirection::Left) + } + (ConfirmResetDevice::Menu, FlowMsg::Cancelled) => { + Decision::Goto(ConfirmResetDevice::Intro, SwipeDirection::Right) + } + (ConfirmResetDevice::Menu, FlowMsg::Choice(0)) => Decision::Return(FlowMsg::Cancelled), + _ => Decision::Nothing, + } + } +} + +use crate::{ + micropython::{buffer::StrBuffer, map::Map, obj::Obj, util}, + ui::layout::obj::LayoutObj, +}; + +pub extern "C" fn new_confirm_reset_device( + n_args: usize, + args: *const Obj, + kwargs: *mut Map, +) -> Obj { + unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, ConfirmResetDevice::new) } +} + +impl ConfirmResetDevice { + fn new(_args: &[Obj], kwargs: &Map) -> Result { + let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; + let par_array: [Paragraph<'static>; 3] = [ + Paragraph::new(&theme::TEXT_MAIN_GREY_LIGHT, TR::reset__by_continuing) + .with_bottom_padding(17), + Paragraph::new(&theme::TEXT_SUB_GREY, TR::reset__more_info_at), + Paragraph::new(&theme::TEXT_SUB_GREY_LIGHT, TR::reset__tos_link), + ]; + let paragraphs = Paragraphs::new(par_array); + let content_intro = Frame::left_aligned(title, SwipePage::vertical(paragraphs)) + .with_menu_button() + .with_footer(TR::instructions__swipe_up.into(), None); + + let content_menu = Frame::left_aligned( + "".into(), + VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[( + "Cancel", // FIXME: use TString + theme::ICON_CANCEL + )]))), + ) + .with_cancel_button(); + + let content_confirm = Frame::left_aligned( + TR::reset__title_create_wallet.into(), + PromptScreen::new_hold_to_confirm(), + ) + .with_footer(TR::instructions__hold_to_confirm.into(), None); + + let store = flow_store() + // Intro, + .add( + Frame::left_aligned(title, SwipePage::vertical(paragraphs)) + .with_info_button() + .with_footer(TR::instructions__swipe_up.into(), None), + |msg| matches!(msg, FrameMsg::Button(_)).then_some(FlowMsg::Info), + )? + // Menu, + .add( + Frame::left_aligned( + "".into(), + VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[( + "Cancel", // FIXME: use TString + theme::ICON_CANCEL + )]))), + ) + .with_cancel_button(), + |msg| match msg { + FrameMsg::Content(VerticalMenuChoiceMsg::Selected(i)) => { + Some(FlowMsg::Choice(i)) + } + FrameMsg::Button(_) => Some(FlowMsg::Cancelled), + }, + )?; + + let res = SwipeFlow::new(ConfirmResetDevice::Intro, store)?; + Ok(LayoutObj::new(res)?.into()) + } +} diff --git a/core/embed/rust/src/ui/model_mercury/flow/get_address.rs b/core/embed/rust/src/ui/model_mercury/flow/get_address.rs index c56037f4f..13065dd9e 100644 --- a/core/embed/rust/src/ui/model_mercury/flow/get_address.rs +++ b/core/embed/rust/src/ui/model_mercury/flow/get_address.rs @@ -12,6 +12,7 @@ use crate::{ }, }, }; +use heapless::Vec; use super::super::{ component::{Frame, FrameMsg, IconDialog, VerticalMenu, VerticalMenuChoiceMsg}, @@ -120,11 +121,11 @@ impl GetAddress { .add( Frame::left_aligned( "".into(), - VerticalMenu::context_menu([ + VerticalMenu::context_menu(unwrap!(Vec::from_slice(&[ ("Address QR code", theme::ICON_QR_CODE), ("Account info", theme::ICON_CHEVRON_RIGHT), ("Cancel trans.", theme::ICON_CANCEL), - ]), + ]))), ) .with_cancel_button(), |msg| match msg { diff --git a/core/embed/rust/src/ui/model_mercury/flow/mod.rs b/core/embed/rust/src/ui/model_mercury/flow/mod.rs index be27c0c4c..6cd147fa8 100644 --- a/core/embed/rust/src/ui/model_mercury/flow/mod.rs +++ b/core/embed/rust/src/ui/model_mercury/flow/mod.rs @@ -1,3 +1,5 @@ pub mod get_address; +pub mod confirm_reset_device; pub use get_address::GetAddress; +pub use confirm_reset_device::ConfirmResetDevice; diff --git a/core/embed/rust/src/ui/model_mercury/layout.rs b/core/embed/rust/src/ui/model_mercury/layout.rs index d61cc7ef6..0e1c65967 100644 --- a/core/embed/rust/src/ui/model_mercury/layout.rs +++ b/core/embed/rust/src/ui/model_mercury/layout.rs @@ -663,28 +663,6 @@ extern "C" fn new_confirm_homescreen(n_args: usize, args: *const Obj, kwargs: *m unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } } -extern "C" fn new_confirm_reset_device(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { - let block = move |_args: &[Obj], kwargs: &Map| { - let title: TString = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; - let button: TString = kwargs.get(Qstr::MP_QSTR_button)?.try_into()?; - - let par_array: [Paragraph<'static>; 3] = [ - Paragraph::new(&theme::TEXT_NORMAL, TR::reset__by_continuing).with_bottom_padding(17), /* simulating a carriage return */ - Paragraph::new(&theme::TEXT_NORMAL, TR::reset__more_info_at), - Paragraph::new(&theme::TEXT_DEMIBOLD, TR::reset__tos_link), - ]; - let paragraphs = Paragraphs::new(par_array); - let buttons = Button::cancel_confirm( - Button::with_icon(theme::ICON_CANCEL), - Button::with_text(button).styled(theme::button_confirm()), - true, - ); - let obj = LayoutObj::new(Frame::left_aligned(title, Dialog::new(paragraphs, buttons)))?; - Ok(obj.into()) - }; - unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } -} - extern "C" fn new_show_address_details(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = move |_args: &[Obj], kwargs: &Map| { let qr_title: TString = kwargs.get(Qstr::MP_QSTR_qr_title)?.try_into()?; @@ -1292,12 +1270,11 @@ extern "C" fn new_show_tx_context_menu(n_args: usize, args: *const Obj, kwargs: let block = move |_args: &[Obj], _kwargs: &Map| { // TODO: this is just POC let title: TString = "".into(); - - let options: [(&'static str, Icon); 3] = [ + let options = unwrap!(Vec::from_slice(&[ ("Address QR code", theme::ICON_QR_CODE), ("Fee info", theme::ICON_CHEVRON_RIGHT), ("Cancel transaction", theme::ICON_CANCEL), - ]; + ])); let content = VerticalMenu::context_menu(options); let frame_with_menu = Frame::left_aligned(title, content).with_cancel_button(); let obj = LayoutObj::new(frame_with_menu)?; @@ -1801,7 +1778,7 @@ pub static mp_module_trezorui2: Module = obj_module! { /// button: str, /// ) -> LayoutObj[UiResult]: /// """Confirm TOS before device setup.""" - Qstr::MP_QSTR_confirm_reset_device => obj_fn_kw!(0, new_confirm_reset_device).as_obj(), + Qstr::MP_QSTR_confirm_reset_device => obj_fn_kw!(0, flow::confirm_reset_device::new_confirm_reset_device).as_obj(), /// def show_address_details( /// *, diff --git a/core/embed/rust/src/ui/model_mercury/theme/mod.rs b/core/embed/rust/src/ui/model_mercury/theme/mod.rs index f33f0b754..41f884e70 100644 --- a/core/embed/rust/src/ui/model_mercury/theme/mod.rs +++ b/core/embed/rust/src/ui/model_mercury/theme/mod.rs @@ -707,8 +707,12 @@ pub const fn loader_lock_icon() -> LoaderStyleSheet { } pub const TEXT_SUPER: TextStyle = TextStyle::new(Font::BIG, GREY_EXTRA_LIGHT, BG, GREY, GREY); -pub const TEXT_MAIN: TextStyle = TextStyle::new(Font::NORMAL, GREY_EXTRA_LIGHT, BG, GREY, GREY); -pub const TEXT_SUB: TextStyle = TextStyle::new(Font::SUB, GREY, BG, GREY, GREY); +pub const TEXT_MAIN_GREY_EXTRA_LIGHT: TextStyle = + TextStyle::new(Font::NORMAL, GREY_EXTRA_LIGHT, BG, GREY, GREY); +pub const TEXT_MAIN_GREY_LIGHT: TextStyle = + TextStyle::new(Font::NORMAL, GREY_LIGHT, BG, GREY, GREY); +pub const TEXT_SUB_GREY_LIGHT: TextStyle = TextStyle::new(Font::SUB, GREY_LIGHT, BG, GREY, GREY); +pub const TEXT_SUB_GREY: TextStyle = TextStyle::new(Font::SUB, GREY, BG, GREY, GREY); pub const TEXT_MONO: TextStyle = TextStyle::new(Font::MONO, GREY_EXTRA_LIGHT, BG, GREY, GREY) .with_line_breaking(LineBreaking::BreakWordsNoHyphen) .with_page_breaking(PageBreaking::CutAndInsertEllipsisBoth) diff --git a/core/mocks/trezortranslate_keys.pyi b/core/mocks/trezortranslate_keys.pyi index 36769be70..12b57f4d6 100644 --- a/core/mocks/trezortranslate_keys.pyi +++ b/core/mocks/trezortranslate_keys.pyi @@ -349,6 +349,7 @@ class TR: inputs__return: str = "RETURN" inputs__show: str = "SHOW" inputs__space: str = "SPACE" + instructions__swipe_up: str = "Swipe up" joint__title: str = "JOINT TRANSACTION" joint__to_the_total_amount: str = "To the total amount:" joint__you_are_contributing: str = "You are contributing:" diff --git a/core/src/trezor/ui/layouts/mercury/__init__.py b/core/src/trezor/ui/layouts/mercury/__init__.py index 2550c6b9c..f946047b4 100644 --- a/core/src/trezor/ui/layouts/mercury/__init__.py +++ b/core/src/trezor/ui/layouts/mercury/__init__.py @@ -322,17 +322,12 @@ async def confirm_single( async def confirm_reset_device(title: str, recovery: bool = False) -> None: - if recovery: - button = TR.reset__button_recover - else: - button = TR.reset__button_create - await raise_if_not_confirmed( interact( RustLayout( trezorui2.confirm_reset_device( - title=title.upper(), - button=button, + title=title, + button="", # not used ) ), "recover_device" if recovery else "setup_device", diff --git a/core/translations/cs.json b/core/translations/cs.json index 482041688..04f3bd73f 100644 --- a/core/translations/cs.json +++ b/core/translations/cs.json @@ -377,6 +377,7 @@ "inputs__previous": "PŘEDCHOZÍ", "inputs__show": "ZOBRAZIT", "inputs__space": "ROZDĚLENÍ", + "instructions__swipe_up": "Swipe up", "joint__title": "SPOLEČNÁ TRANSAKCE", "joint__to_the_total_amount": "Do celkové částky:", "joint__you_are_contributing": "Přispíváte:", diff --git a/core/translations/de.json b/core/translations/de.json index 1b3f695d0..53b8a4b18 100644 --- a/core/translations/de.json +++ b/core/translations/de.json @@ -377,6 +377,7 @@ "inputs__return": "ZURÜCK", "inputs__show": "ANZEIGEN", "inputs__space": "LEER", + "instructions__swipe_up": "Swipe up", "joint__title": "GEMEINS. TRANSAKT.", "joint__to_the_total_amount": "Gesamtbetrag:", "joint__you_are_contributing": "Dein Anteil:", diff --git a/core/translations/en.json b/core/translations/en.json index 7cbd5407f..27a3f3cc4 100644 --- a/core/translations/en.json +++ b/core/translations/en.json @@ -351,6 +351,7 @@ "inputs__previous": "PREVIOUS", "inputs__show": "SHOW", "inputs__space": "SPACE", + "instructions__swipe_up": "Swipe up", "joint__title": "JOINT TRANSACTION", "joint__to_the_total_amount": "To the total amount:", "joint__you_are_contributing": "You are contributing:", diff --git a/core/translations/es.json b/core/translations/es.json index 79d4ea90b..c8864845c 100644 --- a/core/translations/es.json +++ b/core/translations/es.json @@ -377,6 +377,7 @@ "inputs__return": "VOLVER", "inputs__show": "MOSTRAR", "inputs__space": "ESPACIO", + "instructions__swipe_up": "Swipe up", "joint__title": "TRANSACC. CONJUNTA", "joint__to_the_total_amount": "Al importe total:", "joint__you_are_contributing": "Estás aportando:", diff --git a/core/translations/fr.json b/core/translations/fr.json index ab9c073aa..4dead1125 100644 --- a/core/translations/fr.json +++ b/core/translations/fr.json @@ -377,6 +377,7 @@ "inputs__return": "RETOUR", "inputs__show": "AFFICHER", "inputs__space": "ESPACE", + "instructions__swipe_up": "Swipe up", "joint__title": "TRANS. COMMUNE", "joint__to_the_total_amount": "Au montant total :", "joint__you_are_contributing": "Votre contribution :", diff --git a/core/translations/order.json b/core/translations/order.json index 379154902..e33f4cca0 100644 --- a/core/translations/order.json +++ b/core/translations/order.json @@ -843,5 +843,6 @@ "841": "ethereum__staking_unstake_intro", "842": "storage_msg__starting", "843": "storage_msg__verifying_pin", - "844": "storage_msg__wrong_pin" + "844": "storage_msg__wrong_pin", + "845": "instructions__swipe_up" } diff --git a/core/translations/signatures.json b/core/translations/signatures.json index 2432d5225..a008f8cda 100644 --- a/core/translations/signatures.json +++ b/core/translations/signatures.json @@ -1,8 +1,8 @@ { "current": { - "merkle_root": "bb6eb3feee5ec334a310bb083a1c4a84910bbaabc6808dec0ed0ab5831bab390", - "datetime": "2024-04-11T20:31:39.003670", - "commit": "fd71de0c950dfad73d0f783a6cbcb138f711d32c" + "merkle_root": "2f1c1b3de98b6be084e1c1d51f64d7d8d11ecce193563db41065da56ba8d6fba", + "datetime": "2024-04-16T08:55:40.825962", + "commit": "dbba304f5cfd54a0a08e2fa339149dec71ab2299" }, "history": [ {