diff --git a/.gitignore b/.gitignore
index a5690b4f525d2edf429beda0f9338a2481aec2cb..dbc5ad7e8c02e2a93edd7218ec85836f86a60c99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,4 +28,5 @@ last-update
 tools/cipd.gni
 .idea/
 *.iml
+**/Cargo.lock
 **/Cargo.toml
diff --git a/bin/ui/recovery_ui/BUILD.gn b/bin/ui/recovery_ui/BUILD.gn
new file mode 100644
index 0000000000000000000000000000000000000000..3f22415cca5220c9c71800b8a7662d26c349e236
--- /dev/null
+++ b/bin/ui/recovery_ui/BUILD.gn
@@ -0,0 +1,27 @@
+# Copyright 2017 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.
+
+import("//build/rust/rustc_binary.gni")
+import("//build/package.gni")
+
+rustc_binary("bin") {
+  name = "recovery_ui"
+
+  with_lto = "fat"
+
+  deps = [
+    "//garnet/public/rust/crates/fuchsia-zircon",
+    "//garnet/public/rust/crates/fuchsia-async",
+    "//garnet/public/rust/crates/fuchsia-framebuffer",
+  ]
+}
+
+package("recovery_ui") {
+  deps = [
+    ":bin",
+  ]
+
+  binary = "rust_crates/recovery_ui"
+
+}
diff --git a/bin/ui/recovery_ui/src/main.rs b/bin/ui/recovery_ui/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..1502f4bb0298c766414d68dbccf048dae319801f
--- /dev/null
+++ b/bin/ui/recovery_ui/src/main.rs
@@ -0,0 +1,49 @@
+extern crate fuchsia_async as async;
+extern crate fuchsia_framebuffer;
+extern crate fuchsia_zircon;
+
+use fuchsia_framebuffer::{FrameBuffer, PixelFormat};
+use std::io::{self, Read};
+use std::{thread, time};
+
+/// Convenience function that can be called from main and causes the Fuchsia process being
+/// run over ssh to be terminated when the user hits control-C.
+fn wait_for_close() {
+    thread::spawn(move || loop {
+        let mut input = [0; 1];
+        if io::stdin().read_exact(&mut input).is_err() {
+            std::process::exit(0);
+        }
+    });
+}
+
+fn main() {
+    println!("Recovery UI");
+    wait_for_close();
+
+    let mut executor = async::Executor::new().unwrap();
+
+    let fb = FrameBuffer::new(None, &mut executor).unwrap();
+    let config = fb.get_config();
+
+    let values565 = &[31, 248];
+    let values8888 = &[255, 0, 255, 255];
+
+    let pink_frame = fb.new_frame(&mut executor).unwrap();
+
+    for y in 0..config.height {
+        for x in 0..config.width {
+            match config.format {
+                PixelFormat::RgbX888 => pink_frame.write_pixel(x, y, values8888),
+                PixelFormat::Argb8888 => pink_frame.write_pixel(x, y, values8888),
+                PixelFormat::Rgb565 => pink_frame.write_pixel(x, y, values565),
+                _ => {}
+            }
+        }
+    }
+
+    pink_frame.present(&fb).unwrap();
+    loop {
+        thread::sleep(time::Duration::from_millis(25000));
+    }
+}
diff --git a/packages/prod/all b/packages/prod/all
index d03f12d164bdb49e238b3946db8d673282f7c648..4ab5b8b34c54bca3764fd2d7451b0a7a146a1a58 100644
--- a/packages/prod/all
+++ b/packages/prod/all
@@ -50,6 +50,7 @@
         "garnet/packages/prod/power_manager",
         "garnet/packages/prod/ralink",
         "garnet/packages/prod/recovery_netstack",
+        "garnet/packages/prod/recovery_ui",
         "garnet/packages/prod/root_ssl_certificates",
         "garnet/packages/prod/run",
         "garnet/packages/prod/runtime",
diff --git a/packages/prod/recovery_ui b/packages/prod/recovery_ui
new file mode 100644
index 0000000000000000000000000000000000000000..a9d3d9cd8a6c9306fb1f5ffe3b09b394ae2cfe0f
--- /dev/null
+++ b/packages/prod/recovery_ui
@@ -0,0 +1,5 @@
+{
+    "packages": {
+        "recovery_ui": "//garnet/bin/ui/recovery_ui"
+    }
+}
diff --git a/public/rust/crates/BUILD.gn b/public/rust/crates/BUILD.gn
index 5a17a057e19fb1c63e1cd7789ca44705baf2b0e5..5ea52acdf6e405d1ed2bf672bdb1531f323d8e19 100644
--- a/public/rust/crates/BUILD.gn
+++ b/public/rust/crates/BUILD.gn
@@ -12,6 +12,7 @@ package("rust-crates-tests") {
     "fdio",
     "fuchsia-async",
     "fuchsia-syslog",
+    "fuchsia-framebuffer",
     "fuchsia-trace",
     "fuchsia-zircon",
     "shared-buffer",
diff --git a/public/rust/crates/fdio/src/fdio_sys.rs b/public/rust/crates/fdio/src/fdio_sys.rs
index 7ec9a67d77cf69c680892a7812e2d39a2e22cc18..33a751541f2755394a039624ba603b6f4fa93c1e 100644
--- a/public/rust/crates/fdio/src/fdio_sys.rs
+++ b/public/rust/crates/fdio/src/fdio_sys.rs
@@ -166,6 +166,7 @@ pub const IOCTL_FAMILY_CAMERA: raw::c_int = 50;
 pub const IOCTL_FAMILY_BT_HOST: raw::c_int = 51;
 pub const IOCTL_FAMILY_WLANPHY: raw::c_int = 52;
 pub const IOCTL_FAMILY_WLANTAP: raw::c_int = 0x36;
+pub const IOCTL_FAMILY_DISPLAY_CONTROLLER: raw::c_int = 0x37;
 pub const ZXRIO_SOCKET_DIR_NONE: &'static [u8; 5usize] = b"none\x00";
 pub const ZXRIO_SOCKET_DIR_SOCKET: &'static [u8; 7usize] = b"socket\x00";
 pub const ZXRIO_SOCKET_DIR_ACCEPT: &'static [u8; 7usize] = b"accept\x00";
diff --git a/public/rust/crates/fuchsia-framebuffer/BUILD.gn b/public/rust/crates/fuchsia-framebuffer/BUILD.gn
new file mode 100644
index 0000000000000000000000000000000000000000..bf0d3006ae604b909369c0cb400a0802f38d4837
--- /dev/null
+++ b/public/rust/crates/fuchsia-framebuffer/BUILD.gn
@@ -0,0 +1,19 @@
+# Copyright 2017 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.
+
+import("//build/rust/rustc_library.gni")
+
+rustc_library("fuchsia-framebuffer") {
+  name = "fuchsia_framebuffer"
+  version = "0.1.0"
+  deps = [
+    "//garnet/public/rust/crates/fuchsia-async",
+    "//garnet/public/rust/crates/fuchsia-zircon",
+    "//garnet/public/rust/crates/fdio",
+    "//garnet/public/rust/crates/shared-buffer",
+    "//third_party/rust-crates/rustc_deps:failure",
+    "//third_party/rust-crates/rustc_deps:futures",
+    "//zircon/public/fidl/display:display-rustc",
+  ]
+}
diff --git a/public/rust/crates/fuchsia-framebuffer/src/lib.rs b/public/rust/crates/fuchsia-framebuffer/src/lib.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f8659219a76440de80dc34ca9d2db87d2750b95f
--- /dev/null
+++ b/public/rust/crates/fuchsia-framebuffer/src/lib.rs
@@ -0,0 +1,399 @@
+#![allow(dead_code)]
+#[macro_use]
+extern crate failure;
+extern crate fdio;
+extern crate fidl_fuchsia_display as display;
+extern crate fuchsia_async as async;
+extern crate fuchsia_zircon as zx;
+extern crate shared_buffer;
+
+use async::futures::{FutureExt, StreamExt};
+use display::{ControllerEvent, ControllerProxy, ImageConfig};
+use failure::Error;
+use fdio::fdio_sys::{fdio_ioctl, IOCTL_FAMILY_DISPLAY_CONTROLLER, IOCTL_KIND_GET_HANDLE};
+use fdio::make_ioctl;
+use shared_buffer::SharedBuffer;
+use std::cell::RefCell;
+use std::fs::{File, OpenOptions};
+use std::mem;
+use std::os::unix::io::AsRawFd;
+use std::ptr;
+use std::rc::Rc;
+use zx::sys::{zx_cache_flush, zx_handle_t, ZX_CACHE_FLUSH_DATA, ZX_VM_FLAG_PERM_READ,
+              ZX_VM_FLAG_PERM_WRITE};
+use zx::{Handle, Status, Vmar, Vmo};
+
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_NONE: u32 = 0;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_RGB_565: u32 = 131073;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_RGB_332: u32 = 65538;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_RGB_2220: u32 = 65539;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_ARGB_8888: u32 = 262148;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_RGB_x888: u32 = 262149;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_MONO_8: u32 = 65543;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_GRAY_8: u32 = 65543;
+#[allow(non_camel_case_types, non_upper_case_globals)]
+const ZX_PIXEL_FORMAT_MONO_1: u32 = 6;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum PixelFormat {
+    Argb8888,
+    Gray8,
+    Mono1,
+    Mono8,
+    Rgb2220,
+    Rgb332,
+    Rgb565,
+    RgbX888,
+    Unknown,
+}
+
+impl Default for PixelFormat {
+    fn default() -> PixelFormat {
+        PixelFormat::Unknown
+    }
+}
+
+impl From<u32> for PixelFormat {
+    fn from(pixel_format: u32) -> Self {
+        #[allow(non_upper_case_globals)]
+        match pixel_format {
+            ZX_PIXEL_FORMAT_ARGB_8888 => PixelFormat::Argb8888,
+            ZX_PIXEL_FORMAT_MONO_1 => PixelFormat::Mono1,
+            ZX_PIXEL_FORMAT_MONO_8 => PixelFormat::Mono8,
+            ZX_PIXEL_FORMAT_RGB_2220 => PixelFormat::Rgb2220,
+            ZX_PIXEL_FORMAT_RGB_332 => PixelFormat::Rgb332,
+            ZX_PIXEL_FORMAT_RGB_565 => PixelFormat::Rgb565,
+            ZX_PIXEL_FORMAT_RGB_x888 => PixelFormat::RgbX888,
+            // ZX_PIXEL_FORMAT_GRAY_8 is an alias for ZX_PIXEL_FORMAT_MONO_8
+            ZX_PIXEL_FORMAT_NONE => PixelFormat::Unknown,
+            _ => PixelFormat::Unknown,
+        }
+    }
+}
+
+impl Into<u32> for PixelFormat {
+    fn into(self) -> u32 {
+        match self {
+            PixelFormat::Argb8888 => ZX_PIXEL_FORMAT_ARGB_8888,
+            PixelFormat::Mono1 => ZX_PIXEL_FORMAT_MONO_1,
+            PixelFormat::Mono8 => ZX_PIXEL_FORMAT_MONO_8,
+            PixelFormat::Rgb2220 => ZX_PIXEL_FORMAT_RGB_2220,
+            PixelFormat::Rgb332 => ZX_PIXEL_FORMAT_RGB_332,
+            PixelFormat::Rgb565 => ZX_PIXEL_FORMAT_RGB_565,
+            PixelFormat::RgbX888 => ZX_PIXEL_FORMAT_RGB_x888,
+            PixelFormat::Gray8 => ZX_PIXEL_FORMAT_GRAY_8,
+            PixelFormat::Unknown => ZX_PIXEL_FORMAT_NONE,
+        }
+    }
+}
+
+fn pixel_format_bytes(pixel_format: u32) -> usize {
+    ((pixel_format >> 16) & 7) as usize
+}
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct Config {
+    pub display_id: u64,
+    pub width: u32,
+    pub height: u32,
+    pub linear_stride_pixels: u32,
+    pub format: PixelFormat,
+    pub pixel_size_bytes: u32,
+}
+
+impl Config {
+    pub fn linear_stride_bytes(&self) -> usize {
+        self.linear_stride_pixels as usize * self.pixel_size_bytes as usize
+    }
+}
+
+pub struct Frame<'a> {
+    config: Config,
+    image_id: u64,
+    pixel_buffer_addr: usize,
+    pixel_buffer: SharedBuffer<'a>,
+}
+
+impl<'a> Frame<'a> {
+    fn allocate_image_vmo(
+        framebuffer: &FrameBuffer, executor: &mut async::Executor,
+    ) -> Result<Vmo, Error> {
+        let vmo: Rc<RefCell<Option<Vmo>>> = Rc::new(RefCell::new(None));
+        let vmo_response = framebuffer
+            .controller
+            .allocate_vmo(framebuffer.byte_size() as u64)
+            .map(|(status, allocated_vmo)| {
+                if status == Status::OK {
+                    vmo.replace(allocated_vmo);
+                }
+            });
+        executor.run_singlethreaded(vmo_response)?;
+        let vmo = vmo.replace(None);
+        if let Some(vmo) = vmo {
+            Ok(vmo)
+        } else {
+            Err(format_err!("Could not allocate image vmo"))
+        }
+    }
+
+    fn import_image_vmo(
+        framebuffer: &FrameBuffer, executor: &mut async::Executor, image_vmo: Vmo,
+    ) -> Result<u64, Error> {
+        let pixel_format: u32 = framebuffer.config.format.into();
+        let mut image_config = ImageConfig {
+            width: framebuffer.config.width,
+            height: framebuffer.config.height,
+            pixel_format: pixel_format as i32,
+            type_: 0,
+        };
+
+        let image_id: Rc<RefCell<Option<u64>>> = Rc::new(RefCell::new(None));
+        let import_response = framebuffer
+            .controller
+            .import_vmo_image(&mut image_config, image_vmo, 0)
+            .map(|(status, id)| {
+                if status == Status::OK {
+                    image_id.replace(Some(id));
+                }
+            });
+
+        executor.run_singlethreaded(import_response)?;
+
+        let image_id = image_id.replace(None);
+        if let Some(image_id) = image_id {
+            Ok(image_id)
+        } else {
+            Err(format_err!("Could not import image vmo"))
+        }
+    }
+
+    pub fn new(
+        framebuffer: &'a FrameBuffer, executor: &mut async::Executor,
+    ) -> Result<Frame<'a>, Error> {
+        let image_vmo = Self::allocate_image_vmo(framebuffer, executor)?;
+
+        // map image VMO
+        let pixel_buffer_addr = Vmar::root_self().map(
+            0,
+            &image_vmo,
+            0,
+            framebuffer.byte_size(),
+            ZX_VM_FLAG_PERM_READ | ZX_VM_FLAG_PERM_WRITE,
+        )?;
+
+        // import image VMO
+        let image_id = Self::import_image_vmo(framebuffer, executor, image_vmo)?;
+
+        // construct frame
+        let frame_buffer_pixel_ptr = pixel_buffer_addr as *mut u8;
+        Ok(Frame {
+            config: framebuffer.get_config(),
+            image_id: image_id,
+            pixel_buffer_addr,
+            pixel_buffer: unsafe {
+                SharedBuffer::new(frame_buffer_pixel_ptr, framebuffer.byte_size())
+            },
+        })
+    }
+
+    pub fn write_pixel(&self, x: u32, y: u32, value: &[u8]) {
+        let pixel_size = self.config.pixel_size_bytes as usize;
+        let offset = self.linear_stride_bytes() * y as usize + x as usize * pixel_size;
+        self.pixel_buffer.write_at(offset, value);
+    }
+
+    pub fn fill_rectangle(&self, x: u32, y: u32, width: u32, height: u32, value: &[u8]) {
+        let left = x.min(self.config.width);
+        let right = (left + width).min(self.config.width);
+        let top = y.min(self.config.height);
+        let bottom = (top + height).min(self.config.width);
+        for j in top..bottom {
+            for i in left..right {
+                self.write_pixel(i, j, value);
+            }
+        }
+    }
+
+    pub fn present(&self, framebuffer: &FrameBuffer) -> Result<(), Error> {
+        let frame_buffer_pixel_ptr = self.pixel_buffer_addr as *mut u8;
+        let result = unsafe {
+            zx_cache_flush(
+                frame_buffer_pixel_ptr,
+                self.byte_size(),
+                ZX_CACHE_FLUSH_DATA,
+            )
+        };
+        if result != 0 {
+            return Err(format_err!("zx_cache_flush failed: {}", result));
+        }
+        framebuffer
+            .controller
+            .set_display_image(self.config.display_id, self.image_id, 0, 0, 0)?;
+        framebuffer.controller.apply_config()?;
+        Ok(())
+    }
+
+    fn byte_size(&self) -> usize {
+        self.linear_stride_bytes() * self.config.height as usize
+    }
+
+    fn linear_stride_bytes(&self) -> usize {
+        self.config.linear_stride_pixels as usize * self.config.pixel_size_bytes as usize
+    }
+}
+
+impl<'a> Drop for Frame<'a> {
+    fn drop(&mut self) {
+        Vmar::root_self()
+            .unmap(self.pixel_buffer_addr, self.byte_size())
+            .unwrap();
+    }
+}
+
+pub struct FrameBuffer {
+    display_controller: File,
+    controller: ControllerProxy,
+    config: Config,
+}
+
+impl FrameBuffer {
+    fn get_display_handle(file: &File) -> Result<Handle, Error> {
+        let fd = file.as_raw_fd() as i32;
+        let ioctl_display_controller_get_handle =
+            make_ioctl(IOCTL_KIND_GET_HANDLE, IOCTL_FAMILY_DISPLAY_CONTROLLER, 1);
+        let mut display_handle: zx_handle_t = 0;
+        let display_handle_ptr: *mut std::os::raw::c_void =
+            &mut display_handle as *mut _ as *mut std::os::raw::c_void;
+        let result_size = unsafe {
+            fdio_ioctl(
+                fd,
+                ioctl_display_controller_get_handle,
+                ptr::null(),
+                0,
+                display_handle_ptr,
+                mem::size_of::<zx_handle_t>(),
+            )
+        };
+
+        if result_size != mem::size_of::<zx_handle_t>() as isize {
+            return Err(format_err!(
+                "ioctl_display_controller_get_handle failed: {}",
+                result_size
+            ));
+        }
+
+        Ok(unsafe { Handle::from_raw(display_handle) })
+    }
+
+    fn create_config_from_event_stream(
+        proxy: &ControllerProxy, executor: &mut async::Executor,
+    ) -> Result<Config, Error> {
+        let config: Rc<RefCell<Option<Config>>> = Rc::new(RefCell::new(None));
+        let stream = proxy.take_event_stream();
+        let event_listener = stream
+            .filter(|event| {
+                if let ControllerEvent::DisplaysChanged { added, .. } = event {
+                    let mut display_id;
+                    let mut zx_pixel_format = 0;
+                    let mut linear_stride_pixels = 0;
+                    let mut pixel_format = PixelFormat::Unknown;
+                    let mut pixel_size_bytes = 0;
+                    if added.len() > 0 {
+                        let first_added = &added[0];
+                        display_id = first_added.id;
+                        if first_added.pixel_format.len() > 0 {
+                            zx_pixel_format = first_added.pixel_format[0];
+                            pixel_format = zx_pixel_format.into();
+                        }
+                        if first_added.modes.len() > 0 {
+                            let mode = &first_added.modes[0];
+                            if pixel_format != PixelFormat::Unknown {
+                                pixel_size_bytes = pixel_format_bytes(zx_pixel_format);
+                                linear_stride_pixels = mode.horizontal_resolution;
+                            }
+                            let calculated_config = Config {
+                                display_id: display_id,
+                                width: mode.horizontal_resolution,
+                                height: mode.vertical_resolution,
+                                linear_stride_pixels,
+                                format: pixel_format,
+                                pixel_size_bytes: pixel_size_bytes as u32,
+                            };
+                            config.replace(Some(calculated_config));
+                        }
+                    }
+                }
+                Ok(true)
+            })
+            .next();
+
+        executor
+            .run_singlethreaded(event_listener)
+            .map_err(|(e, _rest_of_stream)| e)?;
+
+        let config = config.replace(None);
+        if let Some(config) = config {
+            Ok(config)
+        } else {
+            Err(format_err!("Could not find display"))
+        }
+    }
+
+    pub fn new(
+        display_index: Option<usize>, executor: &mut async::Executor,
+    ) -> Result<FrameBuffer, Error> {
+        let device_path = format!(
+            "/dev/class/display-controller/{:03}",
+            display_index.unwrap_or(0)
+        );
+        let file = OpenOptions::new().read(true).write(true).open(device_path)?;
+        let zx_handle = Self::get_display_handle(&file)?;
+        let channel = async::Channel::from_channel(zx_handle.into())?;
+        let proxy = ControllerProxy::new(channel);
+        let config = Self::create_config_from_event_stream(&proxy, executor)?;
+
+        Ok(FrameBuffer {
+            display_controller: file,
+            controller: proxy,
+            config: config,
+        })
+    }
+
+    pub fn new_frame<'a>(&'a self, executor: &mut async::Executor) -> Result<Frame<'a>, Error> {
+        Frame::new(&self, executor)
+    }
+
+    pub fn get_config(&self) -> Config {
+        self.config
+    }
+
+    pub fn byte_size(&self) -> usize {
+        self.config.height as usize * self.config.linear_stride_bytes()
+    }
+}
+
+impl Drop for FrameBuffer {
+    fn drop(&mut self) {}
+}
+
+#[cfg(test)]
+mod tests {
+    extern crate fuchsia_async as async;
+
+    use FrameBuffer;
+
+    #[test]
+    fn test_framebuffer() {
+        let mut executor = async::Executor::new().unwrap();
+        let fb = FrameBuffer::new(None, &mut executor).unwrap();
+        let _frame = fb.new_frame(&mut executor).unwrap();
+    }
+}