From e3a478044dbf772579ba66ee63ab603baf2505c5 Mon Sep 17 00:00:00 2001 From: matejcik Date: Thu, 16 Nov 2023 13:09:43 +0100 Subject: [PATCH] docs(core): Layout lifecycle documentation --- core/src/trezor/ui/__init__.py | 19 +- docs/SUMMARY.md | 9 +- docs/core/misc/layout-lifecycle.md | 279 +++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 docs/core/misc/layout-lifecycle.md diff --git a/core/src/trezor/ui/__init__.py b/core/src/trezor/ui/__init__.py index ae053c89f3..1c7d42d330 100644 --- a/core/src/trezor/ui/__init__.py +++ b/core/src/trezor/ui/__init__.py @@ -181,8 +181,8 @@ class Layout(Generic[T]): """True if the layout is in RUNNING state.""" return CURRENT_LAYOUT is self - def is_stopped(self) -> bool: - """True if the layout is in STOPPED state.""" + def is_finished(self) -> bool: + """True if the layout is in FINISHED state.""" return CURRENT_LAYOUT is not self and not self.result_box.is_empty() def is_layout_attached(self) -> bool: @@ -191,7 +191,7 @@ class Layout(Generic[T]): def start(self) -> None: """Start the layout, stopping any other RUNNING layout. - If the layout is already RUNNING, do nothing. If the layout is STOPPED, fail. + If the layout is already RUNNING, do nothing. If the layout is FINISHED, fail. """ global CURRENT_LAYOUT @@ -231,7 +231,7 @@ class Layout(Generic[T]): current layout. The resulting state is either READY (if there is no result to be picked up) or - STOPPED. + FINISHED. When called externally, this kills any tasks that wait for the result, assuming that the external `stop()` is a kill. When called internally, `_kill_taker` is @@ -267,7 +267,7 @@ class Layout(Generic[T]): """Wait for, and return, the result of this UI layout.""" if self.is_ready(): self.start() - # else we are (a) still running or (b) already stopped + # else we are (a) still running or (b) already finished try: if self.context is not None and self.result_box.is_empty(): self._start_task(self._handle_usb_iface()) @@ -419,6 +419,15 @@ class Layout(Generic[T]): ), ButtonAck, ) + + if ( + self.button_request_ack_pending + and self.state is LayoutState.TRANSITIONING + ): + self.button_request_ack_pending = False + self.state = LayoutState.ATTACHED + if __debug__: + self.notify_debuglink(self) except Exception: raise diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index a9de23f723..b4dee6579c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -14,12 +14,13 @@ - [Miscellaneous](core/misc/index.md) - [Boot stages](core/misc/boot.md) - [Code style](core/misc/codestyle.md) - - [Memory layout](core/misc/memory.md) - - [SLIP-39](core/misc/slip0039.md) + - [DISC1](core/misc/disc1.md) - [Exceptions usage](core/misc/exceptions.md) - [Memory fragmentation management](core/misc/fragmentation.md) + - [Memory layout](core/misc/memory.md) + - [SLIP-39](core/misc/slip0039.md) - [Translation data format](core/misc/translations.md) - - [DISC1](core/misc/disc1.md) + - [UI Layout lifecycle](core/misc/layout-lifecycle.md) - [Legacy](legacy/index.md) - [Firmware format](legacy/firmware-format.md) - [Python](python/index.md) @@ -43,6 +44,7 @@ - [GitLab CI Jobs](ci/jobs.md) - [Miscellaneous](misc/index.md) - [Affected third-parties](misc/third-parties.md) + - [Changelog](misc/changelog.md) - [Coins' BIP-44 Paths](misc/coins-bip44-paths.md) - [Contributing](misc/contributing.md) - [FW update and device wipe](misc/update-wipes.md) @@ -50,7 +52,6 @@ - [Git Hooks](misc/git-hooks.md) - [Monorepo Notes](misc/monorepo.md) - [Review Process](misc/review.md) - - [Changelog](misc/changelog.md) - [TOIF Image Format](misc/toif.md) - [Developers guide](developers/index.md) - [Libraries](developers/libraries.md) diff --git a/docs/core/misc/layout-lifecycle.md b/docs/core/misc/layout-lifecycle.md new file mode 100644 index 0000000000..7dc66813c9 --- /dev/null +++ b/docs/core/misc/layout-lifecycle.md @@ -0,0 +1,279 @@ +# UI Layout Lifecycle + +## Overview + +There can be at most one UI layout running. The running layout is stored in +`ui.CURRENT_LAYOUT`. The value of this attribute must only be managed internally by the +layout objects themselves. + +There are two kinds of layouts. The `Layout` class represents the normal kind of layout +which can accept user interaction or timer events. Such layout can return a _result_ +of the interaction, retrievable from the `Layout.get_result()` async method. Typically, +calling code will block on an `await` for the result. + +`ProgressLayout` represents loaders for long-running operations. It does not respond to +events and cannot return a result. Calling code will start the progress layout in the +background, call to it to update progress via `ProgressLayout.report()`, and then stop +it when done. + +## Python layout object lifecycle + +A newly created layout object is in **READY** state. It does not accept events, has no +background tasks, does not draw on screen. + +When started, it moves into **RUNNING** state. It is drawn on screen (with backlight +on), accepts events, and runs background tasks. The value of `ui.CURRENT_LAYOUT` is set +to the running layout object. + +(This implies that at most one layout can be in **RUNNING** state.) + +Layout in **RUNNING** state may stop and return a result, either in response to a user +interaction event (touch, button click, USB) or an internal timer firing. This moves it +into a **FINISHED** state. It is no longer shown on screen (backlight is off unless +another layout turns it on again), does not accept events, and does not run background +tasks. + +A layout in a **FINISHED** state has a **result** value, available for pickup by +awaiting `get_result()`. + +Stopping a layout before returning a result, or retrieving a result of a **FINISHED** +layout, will move it back to **READY** state. + +### State transitions + +``` ++-------+ start() +-----------+ +------------+ +| READY | -----------> | RUNNING | ------------> | FINISHED | ++-------+ +-----------+ +------------+ + ^ ^ | | + | | | | + | +------- stop() -------+ | + | | + +--------------------- get_result() -------------------+ +``` + +Calling `start()` checks if other layout is running, and if it is, stops it first. Then +it performs the setup and moves layout into **RUNNING** state. + +(At most one layout can be in **RUNNING** state at one time. That means that before a +layout moves to **RUNNING**, the previously running layout must move out.) + +When layout is in **RUNNING** state, calling `start()` is a no-op. When layout is in +**FINISHED** state, calling `start()` fails an assertion. + +After `start()` returns, the layout is in **RUNNING** state. It will stay in this state +until it returns a result, or is stopped. + +Calling `stop()` on a **READY** or **FINISHED** layout is a no-op. Calling `stop()` on a +**RUNNING** layout will shut down any tasks waiting on the layout's result, and move to +**READY** state. + +After `stop()` returns, the layout is not in **RUNNING** state and the current layout is +no longer this layout. + +Awaiting `get_result()` will resume the lifecycle from its current stage, that is: + +* in **READY** state, starts the layout and waits for its result +* in **RUNNING** state, waits for the result +* in **FINISHED** state, returns the result + +After `get_result()` returns, the layout is in **READY** state. + +All state transitions are synchronous -- so, in terms of trezor-core's cooperative +multitasking, effectively atomic. + +## Global layout lifecycle + +When Trezor boots, `ui.CURRENT_LAYOUT is None`. The screen backlight is on and displays +the "filled lock" welcome screen with model name. + +When a layout is started, the backlight is turned on and the layout is drawn on screen. +`ui.CURRENT_LAYOUT` is the instance of the layout. + +When a layout is stopped, the backlight is turned off and `ui.CURRENT_LAYOUT` is set to +`None`. + +Between two different layouts, there is always an interval where backlight is off and +the value of `ui.CURRENT_LAYOUT` is `None`. This state may not be visible from the +outside; it is possible to synchronously go from `A -> None -> B`. However, there MUST +be a `None` inbetween in all cases. + +## Rust layout object lifecycle + +A layout on the Rust side is represented by the trait `Layout`, whose `event()` method +returns a value of type `Option`. If this event caused a state transition, +the new state is returned. + +Layout can be in one of four states: + +* `Initial`: the layout is freshly constructed. This is never returned as a result of + `event()`. +* `Attached`: the layout is running. Its timers have been started and it is accepting + events. The state transition carries an `Option`. If set, this is the + ButtonRequest that should be sent to the host, as an indication that the layout is + ready. +* `Transitioning`: the layout is running, but not ready to receive events; either a + transition-in or a transition-out animation is running.
+ The enum value carries an `AttachType`, indicating which direction the transition is + going. If this is an outgoing transition, the runtime is supposed to pass the + attach type to the next layout, so that it can properly transition-in. +* `Done`: the layout has finished running. All its timers should be stopped, and there + is a return value available via the `value()` method. + +We currently _do not keep precise track_ of transitioning animations; it would be a lot +of effort to factor the code properly, while the only use case is debuglink state +tracking, which works well enough as-is. + +### Simple layouts + +Layouts that are not flows (i.e., have only one screen) are implemented as `Components` +with a `ComponentMsgObj` implementation. They are wrapped in a `RootComponent` struct +which essentially _simulates_ the layout lifecycle, in the following manner: + +1. At start, the layout is `Initial`. +2. After processing the `Attach` event, the layout is `Attached`. The ButtonRequest + value is picked up from `ctx.button_request()`. +3. When `Component::event()` returns non-`None` value, the layout is `Done`. The return + value is converted to `Obj` via `ComponentMsgObj::msg_try_into_obj()` and cached as + `value` on the `RootComponent`. + +### Flows + +Flow layouts in `mercury` are implemented as a `SwipeFlow` struct, which implements +`Layout` directly. + +A flow lifecycle works like this: + +1. At start, the layout is `Initial`. +2. After processing the `Attach` event, the layout is `Attached`. The ButtonRequest + value is picked up from `ctx.button_request()`. +3. When the flow controller returns a transition from a _swipe_ event, the layout goes + directly to `Attached` state. This is because at that point the transition animation + is already finished. +4. When the flow controller returns a transition from a _non-swipe_ event (e.g., a + button click), the flow controller starts an automatic transition-out animation, and + the layout goes to `Transitioning` state, with the transition direction set to the + swipe animation direction. +5. When the flow controller returns a `Return` decision, the layout goes to `Done`. + +Transition-in animations are currently not tracked properly. This is fine for tests +because animations are disabled there, but it may break at some point. Correctly +tracking transitions would require a more significant refactor of the flow controllers. + +Transition-out animations are partially tracked, when the animation is directed by the +`FlowState` object. In some cases (such as when a swipe is triggered), the animation is +instead controlled by the destination screen, in which case they are not tracked. + +## Button requests + +A `ButtonRequest` MUST be sent while the corresponding layout is already in **RUNNING** +state. That is, in particular, the value of `ui.CURRENT_LAYOUT` is of the corresponding +layout. + +The best choice is to always use the `interact()` function to take care of +`ButtonRequest`s. Explicitly sending `ButtonRequest`s is not supported. + +`ButtonRequest`s sent from Rust get sent as part of the `Attached` state transition, +which can only happen when the layout is already running. + +TODO: instead of relying on `interact()`, it may be better to pass the `ButtonRequest` +inside the layout object and enqueue it so that when the respective Rust layout is +`Attached`, the outside-provided `ButtonRequest` is used. + +## Debuglink + +We assume that only one caller is using the debuglink and that debuglink commands are +strongly ordered on the caller side. On the firmware side, we impose strong ordering on +the received debuglink calls based on the time of arrival. + +There are two layout-relevant debuglink commands. + +### `DebugLinkDecision` + +Caller can send a decision to the **RUNNING** and `Attached` layout. This injects an +event into the layout. In response, the layout can move to a **FINISHED** state. + +If a `DebugLinkDecision` is received while a layout is not **RUNNING** or not +`Attached`, debuglink pauses until some layout becomes ready to receive decisions. + +A next debug command is read only after a `DebugLinkDecision` is fully processed. This +means that: + +* if the decision caused the layout to stop, subsequent debug commands will be received + by the next layout up, +* if the decision caused the layout to transition, subsequent debug commands will be + received by the respective layout when the transition is done, and +* if the decision did not cause the layout to change state, subsequent debug commands + will be received by the same layout. + +### `DebugLinkGetState` + +Caller can read the contents of the **RUNNING** layout. + +There are three available waiting behaviors: + +* `IMMEDIATE` (default) returns the contents of the layout that is currently + **RUNNING**, or empty response if no layout is running. Rust layout lifecycle state is + not taken into account. +* `NEXT_LAYOUT` waits for the layout to change before returning -- that is, waits until + the next time a **RUNNING** layout transitions into an `Attached` state: + - If no layout is running, waits until one is started. + - If a layout is running but not attached, waits until it is attached. + - If a layout is running and attached, waits until the layout stops or becomes + attached again. +* `CURRENT_LAYOUT` waits until a layout is running and attached, and returns its + contents. If no layout is running or it is not attached, the behavior is the same as + `NEXT_LAYOUT`. If a layout is running and attached, the behavior is the same as + `IMMEDIATE`. + +When received after a `ButtonRequest` has been sent, the modes guarantee the following: + +* `IMMEDIATE` and `CURRENT_LAYOUT`: return the contents of the layout corresponding to + the button request (unless the layout has already been terminated by a timer event or + user interaction, in which case the result is undefined). +* `NEXT_LAYOUT`: waits until the layout corresponding to `ButtonRequest` changes. + +When received after a `DebugLinkDecision` has been received, the behavior is: + +* `IMMEDIATE`: If the layout did not shut down (e.g., when paginating), returns the + contents of the layout as modified by the decision. If the layout shut down, the + result is not guaranteed. +* `CURRENT_LAYOUT`: Returns the layout that is the result of the decision. +* `NEXT_LAYOUT`: No guarantees. + +While `DebugLinkGetState` is waiting, **no other debug commands are processed**. In +particular, it is impossible to start waiting and then send a `DebugLinkDecision` to +cause the layout to change. Doing so will result in a deadlock. + +(TODO it _might_ be possible to lift this restriction.) + +If a layout is shut down by a `DebugLinkDecision`, and the firmware expects more +messages, a new layout might not come up until those messages are exchanged. Calling +`DebugLinkGetState` except in `IMMEDIATE` mode will block the debuglink until the new +layout comes up. If the calling code is waiting for a `DebugLinkGetState` to return, it +will deadlock. + +(Firmware tries to detect the above condition and sends an error over debuglink if the +wait state is `CURRENT_LAYOUT` and there is no current layout for more than 3 seconds.) + +## Synchronizing + +`ButtonRequest` is a synchronization event. After a `ButtonRequest` has been sent from +firmware, all debug commands are guaranteed to hit the layout corresponding to the +`ButtonRequest` (unless the layout is terminated by a timer event or user interaction). + +`DebugLinkDecision` is also a synchronization event. After a `DebugLinkDecision` has +been received by the firmware, all debug commands are guaranteed to hit the layout +that is the "result" of the decision. + +In order to synchronize on a homescreen, it is possible to either: + +* invoke any workflow that triggers a `ButtonRequest`, and follow it until end + (`Ping(button_protection=True)` would work fine), or +* poll `DebugLinkGetState` until the layout is `Homescreen`. Typically, running + `DebugLinkGetState(wait_layout=CURRENT_LAYOUT)` will work on the first try if you are + close enough to homescreen (such as after completing a workflow). + +`wait_layout=NEXT_LAYOUT` _cannot_ be used for synchronization, because it always +returns the _next_ layout. If the current one is already homescreen, it will wait +forever.