blob: d56e41e9a336ece90d270c06d2e701c301d2a025 [file] [log] [blame]
// Copyright 2022 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use anyhow::{anyhow, Context, Error};
use carnelian::{
app::{Config, ViewCreationParameters},
color::Color,
drawing::{load_font, DisplayRotation, FontFace},
input, make_message,
render::rive::load_rive,
scene::{
facets::{
FacetId, RiveFacet, SetColorMessage, SetTextMessage, TextFacet, TextFacetOptions,
TextHorizontalAlignment,
},
group::GroupId,
layout::{Alignment, CrossAxisAlignment},
scene::{Scene, SceneBuilder},
},
App, AppAssistant, AppAssistantPtr, AppSender, MessageTarget, Point, Size, ViewAssistant,
ViewAssistantContext, ViewAssistantPtr, ViewKey,
};
use euclid::{point2, size2};
use fidl_fuchsia_boot::ArgumentsMarker;
use fidl_fuchsia_hardware_display::VirtconMode;
use fuchsia_async::{self as fasync, DurationExt};
use fuchsia_fs::directory::{WatchEvent, Watcher};
use fuchsia_zircon as zx;
use futures::StreamExt;
use recovery_ui_config::Config as UiConfig;
use rive_rs as rive;
use std::path::PathBuf;
mod menu;
use menu::{Key, MenuButtonType, MenuEvent, MenuState, MenuStateMachine};
use installer::{
do_install, find_install_source, get_bootloader_type, restart, BootloaderType,
InstallationPaths,
};
use recovery_util_block::{get_block_device, get_block_devices, BlockDevice};
const INSTALLER_HEADLINE: &'static str = "Fuchsia Workstation Installer";
const BG_COLOR: Color = Color { r: 238, g: 23, b: 128, a: 255 };
const WARN_BG_COLOR: Color = Color { r: 158, g: 11, b: 0, a: 255 };
const SUCCESS_BG_COLOR: Color = Color { r: 79, g: 194, b: 50, a: 255 };
const TEXT_COLOR: Color = Color::new(); // Black
const SELECTED_BUTTON_COLOR: Color = Color::white();
// Menu interaction
const HID_USAGE_KEY_UP: u32 = 82;
const HID_USAGE_KEY_DOWN: u32 = 81;
const HID_USAGE_KEY_ENTER: u32 = 40;
const LOGO_IMAGE_PATH: &str = "/pkg/data/logo.riv";
enum InstallerMessages {
MenuUp,
MenuDown,
MenuEnter,
Error(String),
GotInstallSource(BlockDevice),
GotBootloaderType(BootloaderType),
GotInstallDestinations(Vec<BlockDevice>),
GotBlockDevices(Vec<BlockDevice>),
ProgressUpdate(String),
Success,
}
struct InstallerAppAssistant {
display_rotation: DisplayRotation,
automated: bool,
}
impl InstallerAppAssistant {
fn new(display_rotation: DisplayRotation, automated: bool) -> Self {
Self { display_rotation, automated }
}
}
impl AppAssistant for InstallerAppAssistant {
fn setup(&mut self) -> Result<(), Error> {
Ok(())
}
fn create_view_assistant_with_parameters(
&mut self,
params: ViewCreationParameters,
) -> Result<ViewAssistantPtr, Error> {
let logo_file = load_rive(LOGO_IMAGE_PATH).ok();
Ok(Box::new(InstallerViewAssistant::new(
params.app_sender,
params.view_key,
logo_file,
self.automated,
)?))
}
fn filter_config(&mut self, config: &mut Config) {
config.view_mode = carnelian::app::ViewMode::Direct;
config.virtcon_mode = Some(VirtconMode::Forced);
config.display_rotation = self.display_rotation;
config.buffer_count = Some(1);
}
}
struct SceneDetails {
scene: Scene,
size: Size,
background: FacetId,
subheading: FacetId,
message: Option<FacetId>,
buttons: Vec<FacetId>,
message_group: GroupId,
button_group: GroupId,
}
struct InstallerViewAssistant {
app_sender: AppSender,
view_key: ViewKey,
scene_details: Option<SceneDetails>,
face: FontFace,
menu_state_machine: MenuStateMachine,
installation_paths: InstallationPaths,
logo_file: Option<rive::File>,
automated: bool,
}
impl InstallerViewAssistant {
fn new(
app_sender: AppSender,
view_key: ViewKey,
logo_file: Option<rive::File>,
automated: bool,
) -> Result<InstallerViewAssistant, Error> {
let face = load_font(PathBuf::from("/pkg/data/fonts/Roboto-Regular.ttf"))?;
Ok(InstallerViewAssistant {
app_sender,
view_key,
scene_details: None,
face,
menu_state_machine: MenuStateMachine::new(),
installation_paths: InstallationPaths::new(),
logo_file,
automated,
})
}
fn update(&mut self) {
// Update the existing scene with the current state of the menu.
// We don't know what has changed so just update everything.
if let Some(scene_details) = self.scene_details.as_mut() {
scene_details.scene.send_message(
&scene_details.background,
Box::new(SetColorMessage {
color: menu_state_to_background_color(self.menu_state_machine.get_state()),
}),
);
scene_details.scene.send_message(
&scene_details.subheading,
Box::new(SetTextMessage { text: self.menu_state_machine.get_heading() }),
);
// Remove and re-add message.
// Necessary as we can't change the font size of an existing TextFacet.
if let Some(message) = scene_details.message {
scene_details.scene.remove_facet_from_group(message, scene_details.message_group);
scene_details.scene.remove_facet(message).unwrap();
}
let message_text_size = menu_state_to_message_text_size(
self.menu_state_machine.get_state(),
scene_details.size,
);
let message = TextFacet::with_options(
self.face.clone(),
&self.menu_state_machine.get_message(),
message_text_size,
TextFacetOptions {
horizontal_alignment: TextHorizontalAlignment::Center,
color: TEXT_COLOR,
..TextFacetOptions::default()
},
);
let message = scene_details.scene.add_facet(message);
scene_details.scene.add_facet_to_group(message, scene_details.message_group, None);
scene_details.scene.move_facet_forward(message).unwrap();
scene_details.message = Some(message);
// Remove and re-add buttons.
// Necessary so we can have a variable number of buttons.
for button in scene_details.buttons.iter() {
scene_details.scene.remove_facet_from_group(*button, scene_details.button_group);
scene_details.scene.remove_facet(*button).unwrap();
}
scene_details.buttons.clear();
let button_text_size = menu_state_to_button_text_size(
self.menu_state_machine.get_state(),
scene_details.size,
);
for button in self.menu_state_machine.get_buttons() {
let button = TextFacet::with_options(
self.face.clone(),
&button.get_text(),
button_text_size,
TextFacetOptions {
color: if button.is_selected() {
SELECTED_BUTTON_COLOR
} else {
TEXT_COLOR
},
..TextFacetOptions::default()
},
);
let button = scene_details.scene.add_facet(button);
scene_details.scene.add_facet_to_group(button, scene_details.button_group, None);
scene_details.scene.move_facet_forward(button).unwrap();
scene_details.buttons.push(button);
}
}
}
fn handle_installer_message(&mut self, message: &InstallerMessages) {
match message {
// Menu Interaction
InstallerMessages::MenuUp => {
self.menu_state_machine.handle_event(MenuEvent::Navigate(Key::Up));
}
InstallerMessages::MenuDown => {
self.menu_state_machine.handle_event(MenuEvent::Navigate(Key::Down));
}
InstallerMessages::MenuEnter => {
// Get disks if usb install selected
match self.menu_state_machine.get_selected_button_type() {
MenuButtonType::USBInstall => {
// Get installation targets
fasync::Task::local(setup_installation_paths(
self.app_sender.clone(),
self.view_key,
))
.detach();
}
MenuButtonType::Disk(target) => {
// Disk was selected as installation target
self.installation_paths.install_target = Some(target.clone());
self.menu_state_machine.handle_event(MenuEvent::Enter);
}
MenuButtonType::Yes => {
// User agrees to wipe disk and install
self.menu_state_machine.handle_event(MenuEvent::Enter);
fasync::Task::local(fuchsia_install(
self.app_sender.clone(),
self.view_key,
self.installation_paths.clone(),
))
.detach();
}
MenuButtonType::Restart => {
// Restart the machine.
fasync::Task::local(restart()).detach();
}
_ => {
self.menu_state_machine.handle_event(MenuEvent::Enter);
}
}
}
InstallerMessages::Error(error_msg) => {
self.menu_state_machine.handle_event(MenuEvent::Error(error_msg.clone()));
}
InstallerMessages::GotInstallSource(install_source_path) => {
self.installation_paths.install_source = Some(install_source_path.clone());
}
InstallerMessages::GotBootloaderType(bootloader_type) => {
self.installation_paths.bootloader_type = Some(bootloader_type.clone());
}
InstallerMessages::GotInstallDestinations(destinations) => {
self.installation_paths.install_destinations = destinations.clone();
// Send disks to menu
self.menu_state_machine
.handle_event(MenuEvent::GotBlockDevices(destinations.clone()));
}
InstallerMessages::GotBlockDevices(devices) => {
self.installation_paths.available_disks = devices.clone();
}
InstallerMessages::ProgressUpdate(string) => {
self.menu_state_machine.handle_event(MenuEvent::ProgressUpdate(string.clone()));
}
InstallerMessages::Success => {
self.menu_state_machine.handle_event(MenuEvent::Success);
}
}
// Render menu changes
self.app_sender.request_render(self.view_key);
}
}
impl ViewAssistant for InstallerViewAssistant {
fn setup(&mut self, _context: &ViewAssistantContext) -> Result<(), Error> {
if self.automated {
fasync::Task::local(drive_automated_install(self.app_sender.clone(), self.view_key))
.detach();
}
Ok(())
}
fn resize(&mut self, _new_size: &Size) -> Result<(), Error> {
self.scene_details = None;
Ok(())
}
fn get_scene(&mut self, size: Size) -> Option<&mut Scene> {
let scene_details = self.scene_details.take().unwrap_or_else(|| {
// Create the scene from scratch based on the current menu state.
// The scene always has a static heading at the top and logo in the corner.
let min_dimension = size.width.min(size.height);
let mut builder = SceneBuilder::new().round_scene_corners(true);
// Place the logo at the bottom right.
let logo_edge = min_dimension * 0.24;
let logo_size: Size = size2(logo_edge, logo_edge);
let logo_position = {
let x = size.width * 0.8;
let y = size.height * 0.7;
point2(x, y)
};
if let Some(logo_file) = &self.logo_file {
builder.facet_at_location(
Box::new(
RiveFacet::new_from_file(logo_size, &logo_file, None)
.expect("facet_from_file"),
),
logo_position,
);
}
let mut subheading = None;
let mut message_group = None;
let mut button_group = None;
builder.group().stack().expand().align(Alignment::top_center()).contents(
|builder: &mut SceneBuilder| {
builder.group().column().contents(|builder: &mut SceneBuilder| {
// Place the heading at the top.
builder.text(
self.face.clone(),
INSTALLER_HEADLINE,
min_dimension / 10.0,
Point::zero(),
TextFacetOptions { color: TEXT_COLOR, ..TextFacetOptions::default() },
);
builder.space(size / 50.0);
// The remaining parts of the scene are dynamic:
// - A subheading
// - An optional message
// - 0 or more buttons
subheading = Some(builder.text(
self.face.clone(),
&self.menu_state_machine.get_heading(),
min_dimension / 15.0,
Point::zero(),
TextFacetOptions { color: TEXT_COLOR, ..TextFacetOptions::default() },
));
builder.space(size / 10.0);
// Allocate a group for the message.
message_group = Some(builder.group().column().contents(|_| {}));
builder.space(size / 30.0);
// Allocate a group for the buttons.
button_group = Some(
builder
.group()
.column()
.cross_align(CrossAxisAlignment::Start)
.contents(|_| {}),
);
});
},
);
// Set background colour.
// This must be added after everything else to be rendered at the back.
let background = builder.rectangle(
size,
menu_state_to_background_color(self.menu_state_machine.get_state()),
);
let subheading = subheading.unwrap();
let message_group = message_group.unwrap();
let button_group = button_group.unwrap();
SceneDetails {
scene: builder.build(),
size,
background,
subheading,
message: None,
buttons: vec![],
message_group,
button_group,
}
});
self.scene_details = Some(scene_details);
// Fill in the dynamic parts of the scene.
self.update();
Some(&mut self.scene_details.as_mut().unwrap().scene)
}
fn handle_keyboard_event(
&mut self,
context: &mut ViewAssistantContext,
_event: &input::Event,
keyboard_event: &input::keyboard::Event,
) -> Result<(), Error> {
if keyboard_event.phase == input::keyboard::Phase::Pressed {
let pressed_key = keyboard_event.hid_usage;
match pressed_key {
HID_USAGE_KEY_UP => {
context.queue_message(make_message(InstallerMessages::MenuUp));
}
HID_USAGE_KEY_DOWN => {
context.queue_message(make_message(InstallerMessages::MenuDown));
}
HID_USAGE_KEY_ENTER => {
context.queue_message(make_message(InstallerMessages::MenuEnter));
}
_ => {}
}
}
Ok(())
}
fn handle_message(&mut self, message: carnelian::Message) {
if let Some(message) = message.downcast_ref::<InstallerMessages>() {
self.handle_installer_message(message);
}
}
}
fn menu_state_to_background_color(state: MenuState) -> Color {
match state {
MenuState::Warning => WARN_BG_COLOR,
MenuState::Error => WARN_BG_COLOR,
MenuState::Success => SUCCESS_BG_COLOR,
_ => BG_COLOR,
}
}
fn menu_state_to_message_text_size(state: MenuState, screen_size: Size) -> f32 {
let base = screen_size.width.min(screen_size.height);
match state {
MenuState::Warning => base / 20.0,
MenuState::Progress => base / 25.0,
MenuState::Error => base / 20.0,
_ => 0.0,
}
}
fn menu_state_to_button_text_size(state: MenuState, screen_size: Size) -> f32 {
let base = screen_size.width.min(screen_size.height);
match state {
MenuState::SelectInstall => base / 20.0,
MenuState::SelectDisk => base / 33.0,
MenuState::Warning => base / 20.0,
MenuState::Error => base / 20.0,
MenuState::Success => base / 20.0,
_ => 0.0,
}
}
async fn drive_automated_install(app_sender: AppSender, view_key: ViewKey) {
// Simulate pressing enter 3 times.
for _ in 0..3 {
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::MenuEnter),
);
fasync::Timer::new(zx::Duration::from_seconds(1).after_now()).await
}
}
async fn get_installation_paths(app_sender: AppSender, view_key: ViewKey) -> Result<(), Error> {
let block_devices = get_block_devices().await?;
let bootloader_type = get_bootloader_type().await?;
// Send got bootloader message
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::GotBootloaderType(bootloader_type)),
);
// Find the location of the installer
let install_source = find_install_source(&block_devices, bootloader_type).await?;
// Send got installer messgae
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::GotInstallSource(install_source.clone())),
);
// Make list of available destinations for installation
let mut destinations = Vec::new();
for block_device in block_devices.iter() {
if block_device != install_source {
destinations.push(block_device.clone());
}
}
// Send error if no destinations found
if destinations.is_empty() {
return Err(anyhow!("Found no block devices for installation."));
};
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::GotBlockDevices(block_devices)),
);
// Else end destinations
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::GotInstallDestinations(
destinations.into_iter().filter(|d| d.is_disk()).collect(),
)),
);
Ok(())
}
async fn setup_installation_paths(app_sender: AppSender, view_key: ViewKey) {
match get_installation_paths(app_sender.clone(), view_key).await {
Ok(_install_source) => {
tracing::info!("Found installer & block devices ");
}
Err(e) => {
// Send error
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::Error(e.to_string())),
);
tracing::info!("ERROR getting install target: {}", e);
}
};
}
async fn fuchsia_install(
app_sender: AppSender,
view_key: ViewKey,
installation_paths: InstallationPaths,
) {
let sender =
app_sender.create_cross_thread_sender::<InstallerMessages>(MessageTarget::View(view_key));
let progress_callback = |s: String| {
sender.unbounded_send(InstallerMessages::ProgressUpdate(s)).expect("unbounded send failed");
};
// Execute install
match do_install(installation_paths, &progress_callback).await {
Ok(_) => {
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::Success),
);
}
Err(e) => {
tracing::error!("Error while installing: {:#}", e);
app_sender.queue_message(
MessageTarget::View(view_key),
make_message(InstallerMessages::Error(e.to_string())),
);
}
}
}
/// Wait for a display to become available.
async fn wait_for_display() -> Result<(), Error> {
let dir = fuchsia_fs::directory::open_in_namespace(
"/dev/class/display-coordinator",
fuchsia_fs::OpenFlags::empty(),
)
.context("opening display coordinator dir")?;
let mut watcher = Watcher::new(&dir).await.context("starting watch")?;
while let Some(message) = watcher.next().await {
let message = message.context("error on watcher channel")?;
match message.event {
WatchEvent::ADD_FILE | WatchEvent::EXISTING => return Ok(()),
_ => {}
}
}
Err(anyhow!("Didn't find a display device"))
}
/// Check to see if we're doing a non-interactive installs.
/// Non-interactive installs are very limited and will likely only work on systems with a single
/// disk.
/// They are intended to be used in end-to-end tests.
async fn check_is_interactive() -> Result<bool, Error> {
let proxy = fuchsia_component::client::connect_to_protocol::<ArgumentsMarker>()
.context("Connecting to boot arguments service")?;
let automated =
proxy.get_bool("installer.non-interactive", false).await.context("Getting bool")?;
tracing::info!(
"workstation installer: {}doing automated install.",
if automated { "" } else { "not " }
);
if automated {
wait_for_install_disk().await.context("Waiting for install disk")?;
}
Ok(automated)
}
/// Wait for an installation source to become present on the system.
async fn wait_for_install_disk() -> Result<(), Error> {
let dir = fuchsia_fs::directory::open_in_namespace(
"/dev/class/block",
fuchsia_fs::OpenFlags::empty(),
)
.context("opening block dir")?;
let mut watcher = Watcher::new(&dir).await.context("starting watch")?;
let bootloader_type = get_bootloader_type().await?;
let mut devices = vec![];
while let Some(message) = watcher.next().await {
let message = message.context("error on watcher channel")?;
let filename = message.filename.to_str().unwrap();
if filename == "." {
continue;
}
match message.event {
WatchEvent::ADD_FILE | WatchEvent::EXISTING => {
let path = format!("/dev/class/block/{}", filename);
match get_block_device(&path).await {
Ok(Some(bd)) => {
devices.push(bd);
if let Ok(_) = find_install_source(&devices, bootloader_type).await {
return Ok(());
}
}
_ => {}
}
}
_ => {}
}
}
Err(anyhow!("Didn't find an install disk"))
}
#[fuchsia::main]
fn main() -> Result<(), Error> {
tracing::info!("workstation installer: started.");
// Before we give control to carnelian, wait until a display driver is bound.
let (display_result, interactive_result) = fuchsia_async::LocalExecutor::new()
.run_singlethreaded(
async move { futures::join!(wait_for_display(), check_is_interactive()) },
);
display_result.context("Waiting for display controller")?;
let automated = interactive_result.context("Fetching installer boot arguments")?;
let config = UiConfig::take_from_startup_handle();
let display_rotation = match config.display_rotation {
0 => DisplayRotation::Deg0,
180 => DisplayRotation::Deg180,
// Carnelian uses an inverted z-axis for rotation
90 => DisplayRotation::Deg270,
270 => DisplayRotation::Deg90,
val => {
tracing::error!("Invalid display_rotation {}, defaulting to 0 degrees", val);
DisplayRotation::Deg0
}
};
App::run(Box::new(move |_| {
Box::pin(async move {
let assistant = Box::new(InstallerAppAssistant::new(display_rotation, automated));
Ok::<AppAssistantPtr, Error>(assistant)
})
}))
}