1
0
mirror of https://github.com/trezor/trezor-firmware.git synced 2024-12-23 23:08:14 +00:00
trezor-firmware/docs/core/misc/layout-lifecycle.md
2024-11-12 16:55:17 +01:00

13 KiB

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()   +-----------+    <event>    +------------+
| 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<LayoutState>. 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<ButtonRequest>. 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 ButtonRequests. Explicitly sending ButtonRequests is not supported.

ButtonRequests 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.

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.