diff --git a/Cargo.lock b/Cargo.lock
index a81d0a6..2d9e70f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4
 
 [[package]]
 name = "ab_glyph"
@@ -432,6 +432,15 @@ dependencies = [
  "syn 2.0.89",
 ]
 
+[[package]]
+name = "atomic-polyfill"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
+dependencies = [
+ "critical-section",
+]
+
 [[package]]
 name = "atomic-waker"
 version = "1.1.2"
@@ -707,9 +716,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
 
 [[package]]
 name = "bytes"
-version = "1.8.0"
+version = "1.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da"
+checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
 
 [[package]]
 name = "calloop"
@@ -903,6 +912,12 @@ dependencies = [
  "cc",
 ]
 
+[[package]]
+name = "cobs"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67ba02a97a2bd10f4b59b25c7973101c79642302776489e030cd13cdab09ed15"
+
 [[package]]
 name = "codespan-reporting"
 version = "0.11.1"
@@ -1099,6 +1114,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
 [[package]]
 name = "crossbeam-deque"
 version = "0.8.5"
@@ -1362,6 +1383,12 @@ dependencies = [
  "const-random",
 ]
 
+[[package]]
+name = "doctest-file"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562"
+
 [[package]]
 name = "downcast-rs"
 version = "1.2.1"
@@ -1805,6 +1832,15 @@ dependencies = [
  "crunchy",
 ]
 
+[[package]]
+name = "hash32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
+dependencies = [
+ "byteorder",
+]
+
 [[package]]
 name = "hashbrown"
 version = "0.12.3"
@@ -1836,6 +1872,20 @@ dependencies = [
  "hashbrown 0.14.5",
 ]
 
+[[package]]
+name = "heapless"
+version = "0.7.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
+dependencies = [
+ "atomic-polyfill",
+ "hash32",
+ "rustc_version",
+ "serde",
+ "spin",
+ "stable_deref_trait",
+]
+
 [[package]]
 name = "heck"
 version = "0.4.1"
@@ -2139,6 +2189,19 @@ dependencies = [
  "nix 0.29.0",
 ]
 
+[[package]]
+name = "interprocess"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "894148491d817cb36b6f778017b8ac46b17408d522dd90f539d677ea938362eb"
+dependencies = [
+ "doctest-file",
+ "libc",
+ "recvmsg",
+ "widestring",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "io-lifetimes"
 version = "2.0.3"
@@ -3221,6 +3284,17 @@ dependencies = [
  "windows-sys 0.59.0",
 ]
 
+[[package]]
+name = "postcard"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170a2601f67cc9dba8edd8c4870b15f71a6a2dc196daec8c83f72b59dff628a8"
+dependencies = [
+ "cobs",
+ "heapless",
+ "serde",
+]
+
 [[package]]
 name = "ppv-lite86"
 version = "0.2.20"
@@ -3393,6 +3467,12 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "recvmsg"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175"
+
 [[package]]
 name = "redox_syscall"
 version = "0.4.1"
@@ -3808,6 +3888,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
 [[package]]
 name = "stable_deref_trait"
 version = "1.2.0"
@@ -4512,6 +4601,19 @@ dependencies = [
  "pkg-config",
 ]
 
+[[package]]
+name = "wayvr_ipc"
+version = "0.1.0"
+source = "git+https://github.com/olekolek1000/wayvr-ipc.git?rev=c2a6438ffdcc78ff9c0637d914df1bc673723824#c2a6438ffdcc78ff9c0637d914df1bc673723824"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "log",
+ "postcard",
+ "serde",
+ "smallvec",
+]
+
 [[package]]
 name = "web-sys"
 version = "0.3.72"
@@ -4544,6 +4646,12 @@ dependencies = [
  "rustix",
 ]
 
+[[package]]
+name = "widestring"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311"
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
@@ -4957,6 +5065,7 @@ version = "0.6.0"
 dependencies = [
  "anyhow",
  "ash",
+ "bytes",
  "chrono",
  "chrono-tz",
  "clap",
@@ -4973,6 +5082,7 @@ dependencies = [
  "idmap-derive",
  "image_dds",
  "input-linux",
+ "interprocess",
  "json",
  "json5",
  "khronos-egl",
@@ -4984,6 +5094,7 @@ dependencies = [
  "once_cell",
  "openxr",
  "ovr_overlay",
+ "postcard",
  "regex",
  "rodio",
  "rosc",
@@ -5001,6 +5112,7 @@ dependencies = [
  "vulkano-shaders",
  "wayland-client",
  "wayland-egl",
+ "wayvr_ipc",
  "winit",
  "wlx-capture",
  "xcb",
diff --git a/Cargo.toml b/Cargo.toml
index 4c1018c..05be73f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -70,7 +70,9 @@ image_dds = { version = "0.6.0", default-features = false, features = [
 ] }
 mint = "0.5.9"
 
-# WayVR-only deps
+################################
+#WayVR-only deps
+################################
 khronos-egl = { version = "6.0.0", features = ["static"], optional = true }
 smithay = { git = "https://github.com/Smithay/smithay.git", default-features = false, features = [
   "renderer_gl",
@@ -81,12 +83,17 @@ smithay = { git = "https://github.com/Smithay/smithay.git", default-features = f
 uuid = { version = "1.10.0", features = ["v4", "fast-rng"], optional = true }
 wayland-client = { version = "0.31.6", optional = true }
 wayland-egl = { version = "0.32.4", optional = true }
+interprocess = { version = "2.2.2", optional = true }
+postcard = { version = "1.1.1", optional = true }
+bytes = { version = "1.9.0", optional = true }
+wayvr_ipc = { git = "https://github.com/olekolek1000/wayvr-ipc.git", rev = "c2a6438ffdcc78ff9c0637d914df1bc673723824", default-features = false, optional = true }
+################################
 
 [build-dependencies]
 regex = { version = "*" }
 
 [features]
-default = ["openxr", "openvr", "osc", "x11", "wayland", "wayvr"]
+default = ["openvr", "openxr", "osc", "x11", "wayland", "wayvr"]
 openvr = ["dep:ovr_overlay", "dep:json"]
 openxr = ["dep:openxr", "dep:libmonado-rs"]
 osc = ["dep:rosc"]
@@ -101,5 +108,9 @@ wayvr = [
   "dep:uuid",
   "dep:wayland-client",
   "dep:wayland-egl",
+  "dep:interprocess",
+  "dep:postcard",
+  "dep:bytes",
+  "dep:wayvr_ipc",
 ]
 as-raw-xcb-connection = []
diff --git a/src/backend/input.rs b/src/backend/input.rs
index b7eedd0..7d05352 100644
--- a/src/backend/input.rs
+++ b/src/backend/input.rs
@@ -71,6 +71,13 @@ impl InputState {
                 if hand.now.show_hide != hand.before.show_hide {
                     log::debug!("Hand {}: show_hide {}", hand.idx, hand.now.show_hide);
                 }
+                if hand.now.toggle_dashboard != hand.before.toggle_dashboard {
+                    log::debug!(
+                        "Hand {}: toggle_dashboard {}",
+                        hand.idx,
+                        hand.now.toggle_dashboard
+                    );
+                }
                 if hand.now.space_drag != hand.before.space_drag {
                     log::debug!("Hand {}: space_drag {}", hand.idx, hand.now.space_drag);
                 }
@@ -215,6 +222,7 @@ pub struct PointerState {
     pub grab: bool,
     pub alt_click: bool,
     pub show_hide: bool,
+    pub toggle_dashboard: bool,
     pub space_drag: bool,
     pub space_rotate: bool,
     pub space_reset: bool,
diff --git a/src/backend/openvr/input.rs b/src/backend/openvr/input.rs
index 329d633..9b549d8 100644
--- a/src/backend/openvr/input.rs
+++ b/src/backend/openvr/input.rs
@@ -30,16 +30,17 @@ const PATH_HAPTICS: [&str; 2] = [
     "/actions/default/out/HapticsRight",
 ];
 
+const PATH_ALT_CLICK: &str = "/actions/default/in/AltClick";
+const PATH_CLICK_MODIFIER_MIDDLE: &str = "/actions/default/in/ClickModifierMiddle";
+const PATH_CLICK_MODIFIER_RIGHT: &str = "/actions/default/in/ClickModifierRight";
 const PATH_CLICK: &str = "/actions/default/in/Click";
 const PATH_GRAB: &str = "/actions/default/in/Grab";
+const PATH_MOVE_MOUSE: &str = "/actions/default/in/MoveMouse";
 const PATH_SCROLL: &str = "/actions/default/in/Scroll";
-const PATH_ALT_CLICK: &str = "/actions/default/in/AltClick";
 const PATH_SHOW_HIDE: &str = "/actions/default/in/ShowHide";
 const PATH_SPACE_DRAG: &str = "/actions/default/in/SpaceDrag";
 const PATH_SPACE_ROTATE: &str = "/actions/default/in/SpaceRotate";
-const PATH_CLICK_MODIFIER_RIGHT: &str = "/actions/default/in/ClickModifierRight";
-const PATH_CLICK_MODIFIER_MIDDLE: &str = "/actions/default/in/ClickModifierMiddle";
-const PATH_MOVE_MOUSE: &str = "/actions/default/in/MoveMouse";
+const PATH_TOGGLE_DASHBOARD: &str = "/actions/default/in/ToggleDashboard";
 
 const INPUT_ANY: InputValueHandle = InputValueHandle(ovr_overlay::sys::k_ulInvalidInputValueHandle);
 
@@ -51,6 +52,7 @@ pub(super) struct OpenVrInputSource {
     scroll_hnd: ActionHandle,
     alt_click_hnd: ActionHandle,
     show_hide_hnd: ActionHandle,
+    toggle_dashboard_hnd: ActionHandle,
     space_drag_hnd: ActionHandle,
     space_rotate_hnd: ActionHandle,
     click_modifier_right_hnd: ActionHandle,
@@ -75,6 +77,7 @@ impl OpenVrInputSource {
         let scroll_hnd = input.get_action_handle(PATH_SCROLL)?;
         let alt_click_hnd = input.get_action_handle(PATH_ALT_CLICK)?;
         let show_hide_hnd = input.get_action_handle(PATH_SHOW_HIDE)?;
+        let toggle_dashboard_hnd = input.get_action_handle(PATH_TOGGLE_DASHBOARD)?;
         let space_drag_hnd = input.get_action_handle(PATH_SPACE_DRAG)?;
         let space_rotate_hnd = input.get_action_handle(PATH_SPACE_ROTATE)?;
         let click_modifier_right_hnd = input.get_action_handle(PATH_CLICK_MODIFIER_RIGHT)?;
@@ -111,6 +114,7 @@ impl OpenVrInputSource {
             scroll_hnd,
             alt_click_hnd,
             show_hide_hnd,
+            toggle_dashboard_hnd,
             space_drag_hnd,
             space_rotate_hnd,
             click_modifier_right_hnd,
@@ -196,6 +200,11 @@ impl OpenVrInputSource {
                 .map(|x| x.0.bState)
                 .unwrap_or(false);
 
+            app_hand.now.toggle_dashboard = input
+                .get_digital_action_data(self.toggle_dashboard_hnd, hand.input_hnd)
+                .map(|x| x.0.bState)
+                .unwrap_or(false);
+
             app_hand.now.space_drag = input
                 .get_digital_action_data(self.space_drag_hnd, hand.input_hnd)
                 .map(|x| x.0.bState)
diff --git a/src/backend/openvr/lines.rs b/src/backend/openvr/lines.rs
index beea424..9b0b406 100644
--- a/src/backend/openvr/lines.rs
+++ b/src/backend/openvr/lines.rs
@@ -12,7 +12,7 @@ use vulkano::image::view::ImageView;
 use vulkano::image::ImageLayout;
 
 use crate::backend::overlay::{
-    FrameTransform, OverlayData, OverlayRenderer, OverlayState, SplitOverlayBackend,
+    FrameTransform, OverlayData, OverlayRenderer, OverlayState, SplitOverlayBackend, Z_ORDER_LINES,
 };
 use crate::graphics::WlxGraphics;
 use crate::state::AppState;
@@ -82,7 +82,7 @@ impl LinePool {
             },
             ..Default::default()
         };
-        data.state.z_order = 69;
+        data.state.z_order = Z_ORDER_LINES;
         data.state.dirty = true;
 
         self.lines.insert(id, data);
diff --git a/src/backend/openvr/mod.rs b/src/backend/openvr/mod.rs
index 9d85ec4..21c57f0 100644
--- a/src/backend/openvr/mod.rs
+++ b/src/backend/openvr/mod.rs
@@ -43,7 +43,7 @@ use crate::{
 };
 
 #[cfg(feature = "wayvr")]
-use crate::overlays::wayvr::wayvr_action;
+use crate::overlays::wayvr::{wayvr_action, WayVRAction};
 
 pub mod helpers;
 pub mod input;
@@ -293,6 +293,16 @@ pub fn openvr_run(running: Arc<AtomicBool>, show_by_default: bool) -> Result<(),
             overlays.show_hide(&mut state);
         }
 
+        #[cfg(feature = "wayvr")]
+        if state
+            .input_state
+            .pointers
+            .iter()
+            .any(|p| p.now.toggle_dashboard && !p.before.toggle_dashboard)
+        {
+            wayvr_action(&mut state, &mut overlays, &WayVRAction::ToggleDashboard);
+        }
+
         overlays
             .iter_mut()
             .for_each(|o| o.state.auto_movement(&mut state));
@@ -346,7 +356,7 @@ pub fn openvr_run(running: Arc<AtomicBool>, show_by_default: bool) -> Result<(),
 
         #[cfg(feature = "wayvr")]
         if let Some(wayvr) = &state.wayvr {
-            wayvr.borrow_mut().state.tick_finish()?;
+            wayvr.borrow_mut().data.tick_finish()?;
         }
 
         // chaperone
diff --git a/src/backend/openxr/input.rs b/src/backend/openxr/input.rs
index 3b97803..67b101f 100644
--- a/src/backend/openxr/input.rs
+++ b/src/backend/openxr/input.rs
@@ -154,6 +154,7 @@ pub(super) struct OpenXrHandSource {
     action_grab: CustomClickAction,
     action_alt_click: CustomClickAction,
     action_show_hide: CustomClickAction,
+    action_toggle_dashboard: CustomClickAction,
     action_space_drag: CustomClickAction,
     action_space_rotate: CustomClickAction,
     action_space_reset: CustomClickAction,
@@ -365,6 +366,12 @@ impl OpenXrHand {
             session,
         )?;
 
+        pointer.now.toggle_dashboard = self.source.action_toggle_dashboard.state(
+            pointer.before.toggle_dashboard,
+            xr,
+            session,
+        )?;
+
         pointer.now.click_modifier_middle = self.source.action_modifier_middle.state(
             pointer.before.click_modifier_middle,
             xr,
@@ -422,6 +429,7 @@ impl OpenXrHandSource {
             action_scroll,
             action_alt_click: CustomClickAction::new(action_set, "alt_click", side)?,
             action_show_hide: CustomClickAction::new(action_set, "show_hide", side)?,
+            action_toggle_dashboard: CustomClickAction::new(action_set, "toggle_dashboard", side)?,
             action_space_drag: CustomClickAction::new(action_set, "space_drag", side)?,
             action_space_rotate: CustomClickAction::new(action_set, "space_rotate", side)?,
             action_space_reset: CustomClickAction::new(action_set, "space_reset", side)?,
@@ -578,6 +586,14 @@ fn suggest_bindings(instance: &xr::Instance, hands: &[&OpenXrHandSource; 2]) ->
             instance
         );
 
+        add_custom!(
+            profile.toggle_dashboard,
+            &hands[0].action_toggle_dashboard,
+            &hands[1].action_toggle_dashboard,
+            bindings,
+            instance
+        );
+
         add_custom!(
             profile.space_drag,
             &hands[0].action_space_drag,
@@ -655,6 +671,7 @@ struct OpenXrActionConfProfile {
     grab: Option<OpenXrActionConfAction>,
     alt_click: Option<OpenXrActionConfAction>,
     show_hide: Option<OpenXrActionConfAction>,
+    toggle_dashboard: Option<OpenXrActionConfAction>,
     space_drag: Option<OpenXrActionConfAction>,
     space_rotate: Option<OpenXrActionConfAction>,
     space_reset: Option<OpenXrActionConfAction>,
diff --git a/src/backend/openxr/mod.rs b/src/backend/openxr/mod.rs
index b53195d..431010c 100644
--- a/src/backend/openxr/mod.rs
+++ b/src/backend/openxr/mod.rs
@@ -32,7 +32,7 @@ use crate::{
 };
 
 #[cfg(feature = "wayvr")]
-use crate::overlays::wayvr::wayvr_action;
+use crate::overlays::wayvr::{wayvr_action, WayVRAction};
 
 mod helpers;
 mod input;
@@ -291,6 +291,16 @@ pub fn openxr_run(running: Arc<AtomicBool>, show_by_default: bool) -> Result<(),
             overlays.show_hide(&mut app_state);
         }
 
+        #[cfg(feature = "wayvr")]
+        if app_state
+            .input_state
+            .pointers
+            .iter()
+            .any(|p| p.now.toggle_dashboard && !p.before.toggle_dashboard)
+        {
+            wayvr_action(&mut app_state, &mut overlays, &WayVRAction::ToggleDashboard);
+        }
+
         watch_fade(&mut app_state, overlays.mut_by_id(watch_id).unwrap()); // want panic
         if let Some(ref mut space_mover) = playspace {
             space_mover.update(
@@ -414,7 +424,7 @@ pub fn openxr_run(running: Arc<AtomicBool>, show_by_default: bool) -> Result<(),
 
         #[cfg(feature = "wayvr")]
         if let Some(wayvr) = &app_state.wayvr {
-            wayvr.borrow_mut().state.tick_finish()?;
+            wayvr.borrow_mut().data.tick_finish()?;
         }
 
         command_buffer.build_and_execute_now()?;
diff --git a/src/backend/openxr/openxr_actions.json5 b/src/backend/openxr/openxr_actions.json5
index bbbaccf..432b98b 100644
--- a/src/backend/openxr/openxr_actions.json5
+++ b/src/backend/openxr/openxr_actions.json5
@@ -12,6 +12,9 @@
 // -- space_drag --
 // move your stage (playspace drag)
 //
+// -- toggle_dashboard --
+// run or toggle visibility of a previously configured WayVR-compatible dashboard
+//
 // -- space_rotate --
 // rotate your stage (playspace rotate, WIP)
 //
@@ -127,6 +130,10 @@
       left: "/user/hand/left/input/thumbstick/y",
       right: "/user/hand/right/input/thumbstick/y"
     },
+		toggle_dashboard: {
+			double_click: false,
+			right: "/user/hand/right/input/system/click",
+		},
     show_hide: {
       double_click: true,
       left: "/user/hand/left/input/b/click",
diff --git a/src/backend/overlay.rs b/src/backend/overlay.rs
index 2154ede..2f9b356 100644
--- a/src/backend/overlay.rs
+++ b/src/backend/overlay.rs
@@ -28,6 +28,12 @@ pub trait OverlayBackend: OverlayRenderer + InteractionHandler {
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
 pub struct OverlayID(pub usize);
 
+pub const Z_ORDER_TOAST: u32 = 70;
+pub const Z_ORDER_LINES: u32 = 69;
+pub const Z_ORDER_WATCH: u32 = 68;
+pub const Z_ORDER_ANCHOR: u32 = 67;
+pub const Z_ORDER_DASHBOARD: u32 = 66;
+
 pub struct OverlayState {
     pub id: OverlayID,
     pub name: Arc<str>,
diff --git a/src/backend/wayvr/client.rs b/src/backend/wayvr/client.rs
index 3493e99..d060379 100644
--- a/src/backend/wayvr/client.rs
+++ b/src/backend/wayvr/client.rs
@@ -20,7 +20,7 @@ pub struct WayVRClient {
     pub pid: u32,
 }
 
-pub struct WayVRManager {
+pub struct WayVRCompositor {
     pub state: comp::Application,
     pub seat_keyboard: KeyboardHandle<comp::Application>,
     pub seat_pointer: PointerHandle<comp::Application>,
@@ -60,7 +60,7 @@ fn get_wayvr_env_from_pid(pid: i32) -> anyhow::Result<ProcessWayVREnv> {
     Ok(env)
 }
 
-impl WayVRManager {
+impl WayVRCompositor {
     pub fn new(
         state: comp::Application,
         display: wayland_server::Display<comp::Application>,
diff --git a/src/backend/wayvr/display.rs b/src/backend/wayvr/display.rs
index 18efe9b..063eac1 100644
--- a/src/backend/wayvr/display.rs
+++ b/src/backend/wayvr/display.rs
@@ -14,6 +14,7 @@ use smithay::{
     utils::{Logical, Point, Rectangle, Size, Transform},
     wayland::shell::xdg::ToplevelSurface,
 };
+use wayvr_ipc::packet_server;
 
 use crate::{
     backend::{overlay::OverlayID, wayvr::time::get_millis},
@@ -21,7 +22,7 @@ use crate::{
 };
 
 use super::{
-    client::WayVRManager, comp::send_frames_surface_tree, egl_data, event_queue::SyncEventQueue,
+    client::WayVRCompositor, comp::send_frames_surface_tree, egl_data, event_queue::SyncEventQueue,
     process, smithay_wrapper, time, window, WayVRSignal,
 };
 
@@ -45,10 +46,12 @@ pub enum DisplayTask {
     ProcessCleanup(process::ProcessHandle),
 }
 
+const MAX_DISPLAY_SIZE: u16 = 8192;
+
 pub struct Display {
     // Display info stuff
-    pub width: u32,
-    pub height: u32,
+    pub width: u16,
+    pub height: u16,
     pub name: String,
     pub visible: bool,
     pub overlay_id: Option<OverlayID>,
@@ -84,25 +87,41 @@ impl Display {
         renderer: &mut GlesRenderer,
         egl_data: Rc<egl_data::EGLData>,
         wayland_env: super::WaylandEnv,
-        width: u32,
-        height: u32,
+        width: u16,
+        height: u16,
         name: &str,
         primary: bool,
     ) -> anyhow::Result<Self> {
+        if width > MAX_DISPLAY_SIZE {
+            anyhow::bail!(
+                "display width ({}) is larger than {}",
+                width,
+                MAX_DISPLAY_SIZE
+            );
+        }
+
+        if height > MAX_DISPLAY_SIZE {
+            anyhow::bail!(
+                "display height ({}) is larger than {}",
+                height,
+                MAX_DISPLAY_SIZE
+            );
+        }
+
         let tex_format = ffi::RGBA;
         let internal_format = ffi::RGBA8;
 
         let tex_id = renderer.with_context(|gl| {
             smithay_wrapper::create_framebuffer_texture(
                 gl,
-                width,
-                height,
+                width as u32,
+                height as u32,
                 tex_format,
                 internal_format,
             )
         })?;
 
-        let egl_image = egl_data.create_egl_image(tex_id, width, height)?;
+        let egl_image = egl_data.create_egl_image(tex_id, width as u32, height as u32)?;
         let dmabuf_data = egl_data.create_dmabuf_data(&egl_image)?;
 
         let opaque = false;
@@ -131,6 +150,16 @@ impl Display {
         })
     }
 
+    pub fn as_packet(&self, handle: DisplayHandle) -> packet_server::WvrDisplay {
+        packet_server::WvrDisplay {
+            width: self.width,
+            height: self.height,
+            name: self.name.clone(),
+            visible: self.visible,
+            handle: handle.as_packet(),
+        }
+    }
+
     pub fn add_window(
         &mut self,
         window_handle: window::WindowHandle,
@@ -158,7 +187,7 @@ impl Display {
                 let right = (d_next * self.width as f32) as i32;
 
                 window.set_pos(left, 0);
-                window.set_size((right - left) as u32, self.height);
+                window.set_size((right - left) as u32, self.height as u32);
             }
         }
     }
@@ -279,7 +308,7 @@ impl Display {
     pub fn send_mouse_move(
         &self,
         config: &super::Config,
-        manager: &mut WayVRManager,
+        manager: &mut WayVRCompositor,
         x: u32,
         y: u32,
     ) {
@@ -320,7 +349,7 @@ impl Display {
         }
     }
 
-    pub fn send_mouse_down(&mut self, manager: &mut WayVRManager, index: super::MouseIndex) {
+    pub fn send_mouse_down(&mut self, manager: &mut WayVRCompositor, index: super::MouseIndex) {
         // Change keyboard focus to pressed window
         let loc = manager.seat_pointer.current_location();
 
@@ -356,7 +385,7 @@ impl Display {
         manager.seat_pointer.frame(&mut manager.state);
     }
 
-    pub fn send_mouse_up(&self, manager: &mut WayVRManager, index: super::MouseIndex) {
+    pub fn send_mouse_up(&self, manager: &mut WayVRCompositor, index: super::MouseIndex) {
         manager.seat_pointer.button(
             &mut manager.state,
             &input::pointer::ButtonEvent {
@@ -370,7 +399,7 @@ impl Display {
         manager.seat_pointer.frame(&mut manager.state);
     }
 
-    pub fn send_mouse_scroll(&self, manager: &mut WayVRManager, delta: f32) {
+    pub fn send_mouse_scroll(&self, manager: &mut WayVRCompositor, delta: f32) {
         manager.seat_pointer.axis(
             &mut manager.state,
             input::pointer::AxisFrame {
@@ -426,3 +455,19 @@ impl Display {
 }
 
 gen_id!(DisplayVec, Display, DisplayCell, DisplayHandle);
+
+impl DisplayHandle {
+    pub fn from_packet(handle: packet_server::WvrDisplayHandle) -> Self {
+        Self {
+            generation: handle.generation,
+            idx: handle.idx,
+        }
+    }
+
+    pub fn as_packet(&self) -> packet_server::WvrDisplayHandle {
+        packet_server::WvrDisplayHandle {
+            idx: self.idx,
+            generation: self.generation,
+        }
+    }
+}
diff --git a/src/backend/wayvr/handle.rs b/src/backend/wayvr/handle.rs
index 2947e3e..a408d41 100644
--- a/src/backend/wayvr/handle.rs
+++ b/src/backend/wayvr/handle.rs
@@ -8,7 +8,7 @@ macro_rules! gen_id {
         //ThingCell
         pub struct $cell_name {
             pub obj: $instance_name,
-            generation: u64,
+            pub generation: u64,
         }
 
         //ThingVec
@@ -39,6 +39,10 @@ macro_rules! gen_id {
             pub fn id(&self) -> u32 {
                 self.idx
             }
+
+            pub fn new(idx: u32, generation: u64) -> Self {
+                Self { idx, generation }
+            }
         }
 
         //ThingVec
diff --git a/src/backend/wayvr/mod.rs b/src/backend/wayvr/mod.rs
index 1cb0134..bcbd5fc 100644
--- a/src/backend/wayvr/mod.rs
+++ b/src/backend/wayvr/mod.rs
@@ -3,19 +3,18 @@ mod comp;
 pub mod display;
 pub mod egl_data;
 mod egl_ex;
-mod event_queue;
+pub mod event_queue;
 mod handle;
 mod process;
+pub mod server_ipc;
 mod smithay_wrapper;
 mod time;
 mod window;
-
-use std::{cell::RefCell, collections::HashSet, rc::Rc};
-
 use comp::Application;
 use display::DisplayVec;
 use event_queue::SyncEventQueue;
 use process::ProcessVec;
+use server_ipc::WayVRServer;
 use smallvec::SmallVec;
 use smithay::{
     backend::renderer::gles::GlesRenderer,
@@ -29,7 +28,9 @@ use smithay::{
         shm::ShmState,
     },
 };
+use std::{cell::RefCell, collections::HashSet, rc::Rc};
 use time::get_millis;
+use wayvr_ipc::packet_client;
 
 const STR_INVALID_HANDLE_DISP: &str = "Invalid display handle";
 const STR_INVALID_HANDLE_PROCESS: &str = "Invalid process handle";
@@ -78,17 +79,21 @@ pub struct Config {
     pub auto_hide_delay: Option<u32>, // if None, auto-hide is disabled
 }
 
-#[allow(dead_code)]
-pub struct WayVR {
+pub struct WayVRState {
     time_start: u64,
     gles_renderer: GlesRenderer,
     pub displays: display::DisplayVec,
-    pub manager: client::WayVRManager,
+    pub manager: client::WayVRCompositor,
     wm: Rc<RefCell<window::WindowManager>>,
     egl_data: Rc<egl_data::EGLData>,
     pub processes: process::ProcessVec,
     config: Config,
+    dashboard_display: Option<display::DisplayHandle>,
+}
 
+pub struct WayVR {
+    pub state: WayVRState,
+    ipc_server: WayVRServer,
     tasks: SyncEventQueue<WayVRTask>,
     pub signals: SyncEventQueue<WayVRSignal>,
 }
@@ -99,8 +104,9 @@ pub enum MouseIndex {
     Right,
 }
 
-pub enum TickResult {
-    NewExternalProcess(ExternalProcessRequest), // Call WayVRManager::add_client after receiving this message
+pub enum TickTask {
+    NewExternalProcess(ExternalProcessRequest), // Call WayVRCompositor::add_client after receiving this message
+    NewDisplay(packet_client::WvrDisplayCreateParams),
 }
 
 impl WayVR {
@@ -163,23 +169,32 @@ impl WayVR {
         let smithay_context = smithay_wrapper::get_egl_context(&egl_data, &smithay_display)?;
         let gles_renderer = unsafe { GlesRenderer::new(smithay_context)? };
 
-        Ok(Self {
+        let ipc_server = WayVRServer::new()?;
+
+        let state = WayVRState {
             gles_renderer,
             time_start,
-            manager: client::WayVRManager::new(state, display, seat_keyboard, seat_pointer)?,
+            manager: client::WayVRCompositor::new(state, display, seat_keyboard, seat_pointer)?,
             displays: DisplayVec::new(),
             processes: ProcessVec::new(),
             egl_data: Rc::new(egl_data),
             wm: Rc::new(RefCell::new(window::WindowManager::new())),
+            config,
+            dashboard_display: None,
+        };
+
+        Ok(Self {
+            state,
             signals: SyncEventQueue::new(),
             tasks,
-            config,
+            ipc_server,
         })
     }
 
     pub fn tick_display(&mut self, display: display::DisplayHandle) -> anyhow::Result<()> {
         // millis since the start of wayvr
         let display = self
+            .state
             .displays
             .get_mut(&display)
             .ok_or(anyhow::anyhow!(STR_INVALID_HANDLE_DISP))?;
@@ -194,21 +209,27 @@ impl WayVR {
             return Ok(());
         }
 
-        let time_ms = get_millis() - self.time_start;
+        let time_ms = get_millis() - self.state.time_start;
 
-        display.tick_render(&mut self.gles_renderer, time_ms)?;
+        display.tick_render(&mut self.state.gles_renderer, time_ms)?;
         display.wants_redraw = false;
 
         Ok(())
     }
 
-    pub fn tick_events(&mut self) -> anyhow::Result<Vec<TickResult>> {
-        let mut res: Vec<TickResult> = Vec::new();
+    pub fn tick_events(&mut self) -> anyhow::Result<Vec<TickTask>> {
+        let mut tasks: Vec<TickTask> = Vec::new();
+
+        self.ipc_server.tick(&mut server_ipc::TickParams {
+            state: &mut self.state,
+            tasks: &mut tasks,
+        })?;
 
         // Check for redraw events
-        self.displays.iter_mut(&mut |_, disp| {
+        self.state.displays.iter_mut(&mut |_, disp| {
             for disp_window in &disp.displayed_windows {
                 if self
+                    .state
                     .manager
                     .state
                     .check_redraw(disp_window.toplevel.wl_surface())
@@ -221,16 +242,17 @@ impl WayVR {
         // Tick all child processes
         let mut to_remove: SmallVec<[(process::ProcessHandle, display::DisplayHandle); 2]> =
             SmallVec::new();
-        self.processes.iter_mut(&mut |handle, process| {
+
+        self.state.processes.iter_mut(&mut |handle, process| {
             if !process.is_running() {
                 to_remove.push((handle, process.display_handle()));
             }
         });
 
         for (p_handle, disp_handle) in to_remove {
-            self.processes.remove(&p_handle);
+            self.state.processes.remove(&p_handle);
 
-            if let Some(display) = self.displays.get_mut(&disp_handle) {
+            if let Some(display) = self.state.displays.get_mut(&disp_handle) {
                 display
                     .tasks
                     .send(display::DisplayTask::ProcessCleanup(p_handle));
@@ -238,25 +260,26 @@ impl WayVR {
             }
         }
 
-        self.displays.iter_mut(&mut |handle, display| {
-            display.tick(&self.config, &handle, &mut self.signals);
+        self.state.displays.iter_mut(&mut |handle, display| {
+            display.tick(&self.state.config, &handle, &mut self.signals);
         });
 
         while let Some(task) = self.tasks.read() {
             match task {
                 WayVRTask::NewExternalProcess(req) => {
-                    res.push(TickResult::NewExternalProcess(req));
+                    tasks.push(TickTask::NewExternalProcess(req));
                 }
                 WayVRTask::NewToplevel(client_id, toplevel) => {
                     // Attach newly created toplevel surfaces to displays
-                    for client in &self.manager.clients {
+                    for client in &self.state.manager.clients {
                         if client.client.id() == client_id {
-                            let window_handle = self.wm.borrow_mut().create_window(&toplevel);
+                            let window_handle = self.state.wm.borrow_mut().create_window(&toplevel);
 
                             if let Some(process_handle) =
-                                process::find_by_pid(&self.processes, client.pid)
+                                process::find_by_pid(&self.state.processes, client.pid)
                             {
-                                if let Some(display) = self.displays.get_mut(&client.display_handle)
+                                if let Some(display) =
+                                    self.state.displays.get_mut(&client.display_handle)
                                 {
                                     display.add_window(window_handle, process_handle, &toplevel);
                                 } else {
@@ -275,27 +298,60 @@ impl WayVR {
                     }
                 }
                 WayVRTask::ProcessTerminationRequest(process_handle) => {
-                    if let Some(process) = self.processes.get_mut(&process_handle) {
+                    if let Some(process) = self.state.processes.get_mut(&process_handle) {
                         process.terminate();
                     }
                 }
             }
         }
 
-        self.manager
-            .tick_wayland(&mut self.displays, &mut self.processes)?;
+        self.state
+            .manager
+            .tick_wayland(&mut self.state.displays, &mut self.state.processes)?;
 
-        Ok(res)
+        Ok(tasks)
     }
 
     pub fn tick_finish(&mut self) -> anyhow::Result<()> {
-        self.gles_renderer.with_context(|gl| unsafe {
+        self.state.gles_renderer.with_context(|gl| unsafe {
             gl.Flush();
             gl.Finish();
         })?;
         Ok(())
     }
 
+    pub fn get_primary_display(displays: &DisplayVec) -> Option<display::DisplayHandle> {
+        for (idx, cell) in displays.vec.iter().enumerate() {
+            if let Some(cell) = cell {
+                if cell.obj.primary {
+                    return Some(DisplayVec::get_handle(cell, idx));
+                }
+            }
+        }
+        None
+    }
+
+    pub fn get_display_by_name(
+        displays: &DisplayVec,
+        name: &str,
+    ) -> Option<display::DisplayHandle> {
+        for (idx, cell) in displays.vec.iter().enumerate() {
+            if let Some(cell) = cell {
+                if cell.obj.name == name {
+                    return Some(DisplayVec::get_handle(cell, idx));
+                }
+            }
+        }
+        None
+    }
+
+    pub fn terminate_process(&mut self, process_handle: process::ProcessHandle) {
+        self.tasks
+            .send(WayVRTask::ProcessTerminationRequest(process_handle));
+    }
+}
+
+impl WayVRState {
     pub fn send_mouse_move(&mut self, display: display::DisplayHandle, x: u32, y: u32) {
         if let Some(display) = self.displays.get(&display) {
             display.send_mouse_move(&self.config, &mut self.manager, x, y);
@@ -336,35 +392,10 @@ impl WayVR {
             .map(|display| display.dmabuf_data.clone())
     }
 
-    pub fn get_primary_display(displays: &DisplayVec) -> Option<display::DisplayHandle> {
-        for (idx, cell) in displays.vec.iter().enumerate() {
-            if let Some(cell) = cell {
-                if cell.obj.primary {
-                    return Some(DisplayVec::get_handle(cell, idx));
-                }
-            }
-        }
-        None
-    }
-
-    pub fn get_display_by_name(
-        displays: &DisplayVec,
-        name: &str,
-    ) -> Option<display::DisplayHandle> {
-        for (idx, cell) in displays.vec.iter().enumerate() {
-            if let Some(cell) = cell {
-                if cell.obj.name == name {
-                    return Some(DisplayVec::get_handle(cell, idx));
-                }
-            }
-        }
-        None
-    }
-
     pub fn create_display(
         &mut self,
-        width: u32,
-        height: u32,
+        width: u16,
+        height: u16,
         name: &str,
         primary: bool,
     ) -> anyhow::Result<display::DisplayHandle> {
@@ -381,6 +412,25 @@ impl WayVR {
         Ok(self.displays.add(display))
     }
 
+    pub fn get_or_create_dashboard_display(
+        &mut self,
+        width: u16,
+        height: u16,
+        name: &str,
+    ) -> anyhow::Result<(bool /* newly created? */, display::DisplayHandle)> {
+        if let Some(handle) = &self.dashboard_display {
+            // ensure it still exists
+            if self.displays.get(handle).is_some() {
+                return Ok((false, *handle));
+            }
+        }
+
+        let new_disp = self.create_display(width, height, name, false)?;
+        self.dashboard_display = Some(new_disp);
+
+        Ok((true, new_disp))
+    }
+
     pub fn destroy_display(&mut self, handle: display::DisplayHandle) {
         self.displays.remove(&handle);
     }
@@ -410,11 +460,6 @@ impl WayVR {
         None
     }
 
-    pub fn terminate_process(&mut self, process_handle: process::ProcessHandle) {
-        self.tasks
-            .send(WayVRTask::ProcessTerminationRequest(process_handle));
-    }
-
     pub fn add_external_process(
         &mut self,
         display_handle: display::DisplayHandle,
diff --git a/src/backend/wayvr/process.rs b/src/backend/wayvr/process.rs
index c6c08da..395c5d1 100644
--- a/src/backend/wayvr/process.rs
+++ b/src/backend/wayvr/process.rs
@@ -1,3 +1,5 @@
+use wayvr_ipc::packet_server;
+
 use crate::gen_id;
 
 use super::display;
@@ -43,6 +45,21 @@ impl Process {
             Process::External(p) => p.terminate(),
         }
     }
+
+    pub fn to_packet(&self, handle: ProcessHandle) -> packet_server::WvrProcess {
+        match self {
+            Process::Managed(p) => packet_server::WvrProcess {
+                name: p.get_name().unwrap_or(String::from("unknown")),
+                display_handle: p.display_handle.as_packet(),
+                handle: handle.as_packet(),
+            },
+            Process::External(p) => packet_server::WvrProcess {
+                name: p.get_name().unwrap_or(String::from("unknown")),
+                display_handle: p.display_handle.as_packet(),
+                handle: handle.as_packet(),
+            },
+        }
+    }
 }
 
 impl Drop for WayVRProcess {
@@ -74,6 +91,23 @@ impl WayVRProcess {
             libc::kill(self.child.id() as i32, libc::SIGTERM);
         }
     }
+
+    pub fn get_name(&self) -> Option<String> {
+        get_exec_name_from_pid(self.child.id())
+    }
+}
+
+fn get_exec_name_from_pid(pid: u32) -> Option<String> {
+    let path = format!("/proc/{}/exe", pid);
+    match std::fs::read_link(&path) {
+        Ok(buf) => {
+            if let Some(process_name) = buf.file_name().and_then(|s| s.to_str()) {
+                return Some(String::from(process_name));
+            }
+            None
+        }
+        Err(_) => None,
+    }
 }
 
 impl ExternalProcess {
@@ -94,6 +128,10 @@ impl ExternalProcess {
         }
         self.pid = 0;
     }
+
+    pub fn get_name(&self) -> Option<String> {
+        get_exec_name_from_pid(self.pid)
+    }
 }
 
 gen_id!(ProcessVec, Process, ProcessCell, ProcessHandle);
@@ -117,3 +155,19 @@ pub fn find_by_pid(processes: &ProcessVec, pid: u32) -> Option<ProcessHandle> {
     }
     None
 }
+
+impl ProcessHandle {
+    pub fn from_packet(handle: packet_server::WvrProcessHandle) -> Self {
+        Self {
+            generation: handle.generation,
+            idx: handle.idx,
+        }
+    }
+
+    pub fn as_packet(&self) -> packet_server::WvrProcessHandle {
+        packet_server::WvrProcessHandle {
+            idx: self.idx,
+            generation: self.generation,
+        }
+    }
+}
diff --git a/src/backend/wayvr/server_ipc.rs b/src/backend/wayvr/server_ipc.rs
new file mode 100644
index 0000000..418f791
--- /dev/null
+++ b/src/backend/wayvr/server_ipc.rs
@@ -0,0 +1,418 @@
+use super::{display, process, TickTask};
+use bytes::BufMut;
+use interprocess::local_socket::{self, traits::Listener, ToNsName};
+use smallvec::SmallVec;
+use std::io::{Read, Write};
+use wayvr_ipc::{
+    ipc::{self, binary_decode, binary_encode},
+    packet_client::{self, PacketClient},
+    packet_server::{self, PacketServer},
+};
+
+pub struct Connection {
+    alive: bool,
+    conn: local_socket::Stream,
+    next_packet: Option<u32>,
+    handshaking: bool,
+}
+
+pub fn send_packet(conn: &mut local_socket::Stream, data: &[u8]) -> anyhow::Result<()> {
+    let mut bytes = bytes::BytesMut::new();
+
+    // packet size
+    bytes.put_u32(data.len() as u32);
+
+    // packet data
+    bytes.put_slice(data);
+
+    conn.write_all(&bytes)?;
+
+    Ok(())
+}
+
+fn read_check(expected_size: u32, res: std::io::Result<usize>) -> bool {
+    match res {
+        Ok(count) => {
+            if count == 0 {
+                return false;
+            }
+            if count as u32 != expected_size {
+                log::error!("count {} is not {}", count, expected_size);
+                false
+            } else {
+                true // read succeeded
+            }
+        }
+        Err(_e) => {
+            //log::error!("failed to get packet size: {}", e);
+            false
+        }
+    }
+}
+
+type Payload = SmallVec<[u8; 64]>;
+
+fn read_payload(conn: &mut local_socket::Stream, size: u32) -> Option<Payload> {
+    let mut payload = Payload::new();
+    payload.resize(size as usize, 0);
+    if !read_check(size, conn.read(&mut payload)) {
+        None
+    } else {
+        Some(payload)
+    }
+}
+
+pub struct TickParams<'a> {
+    pub state: &'a mut super::WayVRState,
+    pub tasks: &'a mut Vec<TickTask>,
+}
+
+pub fn gen_args_vec(input: &str) -> Vec<&str> {
+    input.split_whitespace().collect()
+}
+
+pub fn gen_env_vec(input: &Vec<String>) -> Vec<(&str, &str)> {
+    let res = input
+        .iter()
+        .filter_map(|e| e.as_str().split_once('='))
+        .collect();
+    res
+}
+
+impl Connection {
+    fn new(conn: local_socket::Stream) -> Self {
+        Self {
+            conn,
+            alive: true,
+            handshaking: true,
+            next_packet: None,
+        }
+    }
+
+    fn kill(&mut self) {
+        self.alive = false;
+    }
+
+    fn process_handshake(&mut self, payload: Payload) -> anyhow::Result<()> {
+        let Ok(handshake) = binary_decode::<ipc::Handshake>(&payload) else {
+            anyhow::bail!("Invalid handshake");
+        };
+
+        if handshake.protocol_version != ipc::PROTOCOL_VERSION {
+            anyhow::bail!(
+                "Unsupported protocol version {}",
+                handshake.protocol_version
+            );
+        }
+
+        if handshake.magic != ipc::CONNECTION_MAGIC {
+            anyhow::bail!("Invalid magic");
+        }
+
+        log::info!("Accepted new connection");
+        self.handshaking = false;
+        Ok(())
+    }
+
+    fn handle_wvr_display_list(
+        &mut self,
+        params: &TickParams,
+        serial: ipc::Serial,
+    ) -> anyhow::Result<()> {
+        let list: Vec<packet_server::WvrDisplay> = params
+            .state
+            .displays
+            .vec
+            .iter()
+            .enumerate()
+            .filter_map(|(idx, opt_cell)| {
+                let Some(cell) = opt_cell else {
+                    return None;
+                };
+                let display = &cell.obj;
+                Some(display.as_packet(display::DisplayHandle::new(idx as u32, cell.generation)))
+            })
+            .collect();
+
+        send_packet(
+            &mut self.conn,
+            &binary_encode(&PacketServer::WvrDisplayListResponse(
+                serial,
+                packet_server::WvrDisplayList { list },
+            )),
+        )?;
+
+        Ok(())
+    }
+
+    fn handle_wvr_display_create(
+        &mut self,
+        params: &mut TickParams,
+        serial: ipc::Serial,
+        packet_params: packet_client::WvrDisplayCreateParams,
+    ) -> anyhow::Result<()> {
+        let display_handle = params.state.create_display(
+            packet_params.width,
+            packet_params.height,
+            &packet_params.name,
+            false,
+        )?;
+
+        params
+            .tasks
+            .push(TickTask::NewDisplay(packet_params.clone()));
+
+        send_packet(
+            &mut self.conn,
+            &binary_encode(&PacketServer::WvrDisplayCreateResponse(
+                serial,
+                display_handle.as_packet(),
+            )),
+        )?;
+        Ok(())
+    }
+
+    fn handle_wvr_process_launch(
+        &mut self,
+        params: &mut TickParams,
+        serial: ipc::Serial,
+        packet_params: packet_client::WvrProcessLaunchParams,
+    ) -> anyhow::Result<()> {
+        let args_vec = gen_args_vec(&packet_params.args);
+        let env_vec = gen_env_vec(&packet_params.env);
+
+        let res = params.state.spawn_process(
+            super::display::DisplayHandle::from_packet(packet_params.target_display),
+            &packet_params.exec,
+            &args_vec,
+            &env_vec,
+        );
+
+        let res = res.map(|r| r.as_packet()).map_err(|e| e.to_string());
+
+        send_packet(
+            &mut self.conn,
+            &binary_encode(&PacketServer::WvrProcessLaunchResponse(serial, res)),
+        )?;
+
+        Ok(())
+    }
+
+    fn handle_wvr_process_get(
+        &mut self,
+        params: &TickParams,
+        serial: ipc::Serial,
+        display_handle: packet_server::WvrDisplayHandle,
+    ) -> anyhow::Result<()> {
+        let native_handle = &display::DisplayHandle::from_packet(display_handle.clone());
+        let disp = params
+            .state
+            .displays
+            .get(native_handle)
+            .map(|disp| disp.as_packet(*native_handle));
+
+        send_packet(
+            &mut self.conn,
+            &binary_encode(&PacketServer::WvrDisplayGetResponse(serial, disp)),
+        )?;
+
+        Ok(())
+    }
+
+    fn handle_wvr_process_list(
+        &mut self,
+        params: &TickParams,
+        serial: ipc::Serial,
+    ) -> anyhow::Result<()> {
+        let list: Vec<packet_server::WvrProcess> = params
+            .state
+            .processes
+            .vec
+            .iter()
+            .enumerate()
+            .filter_map(|(idx, opt_cell)| {
+                let Some(cell) = opt_cell else {
+                    return None;
+                };
+                let process = &cell.obj;
+                Some(process.to_packet(process::ProcessHandle::new(idx as u32, cell.generation)))
+            })
+            .collect();
+
+        send_packet(
+            &mut self.conn,
+            &binary_encode(&PacketServer::WvrProcessListResponse(
+                serial,
+                packet_server::WvrProcessList { list },
+            )),
+        )?;
+
+        Ok(())
+    }
+
+    // This request doesn't return anything to the client
+    fn handle_wvr_process_terminate(
+        &mut self,
+        params: &mut TickParams,
+        process_handle: packet_server::WvrProcessHandle,
+    ) -> anyhow::Result<()> {
+        let native_handle = &process::ProcessHandle::from_packet(process_handle.clone());
+        let process = params.state.processes.get_mut(native_handle);
+
+        let Some(process) = process else {
+            return Ok(());
+        };
+
+        process.terminate();
+
+        Ok(())
+    }
+
+    fn process_payload(&mut self, params: &mut TickParams, payload: Payload) -> anyhow::Result<()> {
+        if self.handshaking {
+            self.process_handshake(payload)?;
+            return Ok(());
+        }
+
+        let packet: PacketClient = binary_decode(&payload)?;
+        match packet {
+            PacketClient::WvrDisplayList(serial) => {
+                self.handle_wvr_display_list(params, serial)?;
+            }
+            PacketClient::WvrDisplayGet(serial, display_handle) => {
+                self.handle_wvr_process_get(params, serial, display_handle)?;
+            }
+            PacketClient::WvrProcessList(serial) => {
+                self.handle_wvr_process_list(params, serial)?;
+            }
+            PacketClient::WvrProcessLaunch(serial, packet_params) => {
+                self.handle_wvr_process_launch(params, serial, packet_params)?;
+            }
+            PacketClient::WvrDisplayCreate(serial, packet_params) => {
+                self.handle_wvr_display_create(params, serial, packet_params)?;
+            }
+            PacketClient::WvrProcessTerminate(process_handle) => {
+                self.handle_wvr_process_terminate(params, process_handle)?;
+            }
+        }
+
+        Ok(())
+    }
+
+    fn process_check_payload(&mut self, params: &mut TickParams, payload: Payload) -> bool {
+        log::debug!("payload size {}", payload.len());
+
+        if let Err(e) = self.process_payload(params, payload) {
+            log::error!("Invalid payload from the client, closing connection: {}", e);
+            self.kill();
+            false
+        } else {
+            true
+        }
+    }
+
+    fn read_packet(&mut self, params: &mut TickParams) -> bool {
+        if let Some(payload_size) = self.next_packet {
+            let Some(payload) = read_payload(&mut self.conn, payload_size) else {
+                // still failed to read payload, try in next tick
+                return false;
+            };
+
+            if !self.process_check_payload(params, payload) {
+                return false;
+            }
+
+            self.next_packet = None;
+        }
+
+        let mut buf_packet_header: [u8; 4] = [0; 4];
+        if !read_check(4, self.conn.read(&mut buf_packet_header)) {
+            return false;
+        }
+
+        let payload_size = u32::from_be_bytes(buf_packet_header[0..4].try_into().unwrap()); // 0-3 bytes (u32 size)
+
+        let size_limit: u32 = 128 * 1024;
+
+        if payload_size > size_limit {
+            // over 128 KiB?
+            log::error!(
+                "Client sent a packet header with the size over {} bytes, closing connection.",
+                size_limit
+            );
+            self.kill();
+            return false;
+        }
+
+        let Some(payload) = read_payload(&mut self.conn, payload_size) else {
+            // failed to read payload, try in next tick
+            self.next_packet = Some(payload_size);
+            return false;
+        };
+
+        if !self.process_check_payload(params, payload) {
+            return false;
+        }
+
+        true
+    }
+
+    fn tick(&mut self, params: &mut TickParams) {
+        while self.read_packet(params) {}
+    }
+}
+
+impl Drop for Connection {
+    fn drop(&mut self) {
+        log::info!("Connection closed");
+    }
+}
+
+pub struct WayVRServer {
+    listener: local_socket::Listener,
+    connections: Vec<Connection>,
+}
+
+impl WayVRServer {
+    pub fn new() -> anyhow::Result<Self> {
+        let printname = "/tmp/wayvr_ipc.sock";
+        let name = printname.to_ns_name::<local_socket::GenericNamespaced>()?;
+        let opts = local_socket::ListenerOptions::new()
+            .name(name)
+            .nonblocking(local_socket::ListenerNonblockingMode::Both);
+        let listener = match opts.create_sync() {
+            Ok(listener) => listener,
+            Err(e) => anyhow::bail!("Failed to start WayVRServer IPC listener. Reason: {}", e),
+        };
+
+        log::info!("WayVRServer IPC running at {}", printname);
+
+        Ok(Self {
+            listener,
+            connections: Vec::new(),
+        })
+    }
+
+    fn accept_connections(&mut self) {
+        let Ok(conn) = self.listener.accept() else {
+            return; // No new connection or other error
+        };
+
+        self.connections.push(Connection::new(conn));
+    }
+
+    fn tick_connections(&mut self, params: &mut TickParams) {
+        for c in &mut self.connections {
+            c.tick(params);
+        }
+
+        // remove killed connections
+        self.connections.retain(|c| c.alive);
+    }
+
+    pub fn tick(&mut self, params: &mut TickParams) -> anyhow::Result<()> {
+        self.accept_connections();
+        self.tick_connections(params);
+        Ok(())
+    }
+}
diff --git a/src/config_wayvr.rs b/src/config_wayvr.rs
index f212a9a..f8c5dd9 100644
--- a/src/config_wayvr.rs
+++ b/src/config_wayvr.rs
@@ -17,7 +17,7 @@ use crate::{
         wayvr,
     },
     config::{load_known_yaml, ConfigType},
-    overlays::wayvr::{WayVRAction, WayVRState},
+    overlays::wayvr::{WayVRAction, WayVRData},
 };
 
 // Flat version of RelativeTo
@@ -40,6 +40,16 @@ impl AttachTo {
             AttachTo::Head => RelativeTo::Head,
         }
     }
+
+    pub fn from_packet(input: &wayvr_ipc::packet_client::AttachTo) -> AttachTo {
+        match input {
+            wayvr_ipc::packet_client::AttachTo::None => AttachTo::None,
+            wayvr_ipc::packet_client::AttachTo::HandLeft => AttachTo::HandLeft,
+            wayvr_ipc::packet_client::AttachTo::HandRight => AttachTo::HandRight,
+            wayvr_ipc::packet_client::AttachTo::Head => AttachTo::Head,
+            wayvr_ipc::packet_client::AttachTo::Stage => AttachTo::Stage,
+        }
+    }
 }
 
 #[derive(Clone, Deserialize, Serialize)]
@@ -60,8 +70,8 @@ pub struct WayVRAppEntry {
 
 #[derive(Clone, Deserialize, Serialize)]
 pub struct WayVRDisplay {
-    pub width: u32,
-    pub height: u32,
+    pub width: u16,
+    pub height: u16,
     pub scale: Option<f32>,
     pub rotation: Option<Rotation>,
     pub pos: Option<[f32; 3]>,
@@ -96,12 +106,20 @@ fn def_keyboard_repeat_rate() -> u32 {
     50
 }
 
+#[derive(Deserialize, Serialize)]
+pub struct WayVRDashboard {
+    pub exec: String,
+    pub args: Option<String>,
+    pub env: Option<Vec<String>>,
+}
+
 #[derive(Deserialize, Serialize)]
 pub struct WayVRConfig {
     pub version: u32,
     pub run_compositor_at_start: bool,
     pub catalogs: HashMap<String, WayVRCatalog>,
     pub displays: BTreeMap<String, WayVRDisplay>, // sorted alphabetically
+    pub dashboard: WayVRDashboard,
 
     #[serde(default = "def_true")]
     pub auto_hide: bool,
@@ -154,7 +172,7 @@ impl WayVRConfig {
         &self,
         config: &crate::config::GeneralConfig,
         tasks: &mut TaskContainer,
-    ) -> anyhow::Result<Option<Rc<RefCell<WayVRState>>>> {
+    ) -> anyhow::Result<Option<Rc<RefCell<WayVRData>>>> {
         let primary_count = self
             .displays
             .iter()
@@ -182,8 +200,8 @@ impl WayVRConfig {
 
         if self.run_compositor_at_start {
             // Start Wayland server instantly
-            Ok(Some(Rc::new(RefCell::new(WayVRState::new(
-                Self::get_wayvr_config(config, &self),
+            Ok(Some(Rc::new(RefCell::new(WayVRData::new(
+                Self::get_wayvr_config(config, self),
             )?))))
         } else {
             // Lazy-init WayVR later if the user requested
diff --git a/src/overlays/anchor.rs b/src/overlays/anchor.rs
index 1e44cf0..1643170 100644
--- a/src/overlays/anchor.rs
+++ b/src/overlays/anchor.rs
@@ -2,7 +2,7 @@ use glam::Vec3A;
 use once_cell::sync::Lazy;
 use std::sync::Arc;
 
-use crate::backend::overlay::{OverlayData, OverlayState, RelativeTo};
+use crate::backend::overlay::{OverlayData, OverlayState, RelativeTo, Z_ORDER_ANCHOR};
 use crate::config::{load_known_yaml, ConfigType};
 use crate::gui::modular::{modular_canvas, ModularUiConfig};
 use crate::state::AppState;
@@ -21,7 +21,7 @@ where
             want_visible: false,
             interactable: false,
             grabbable: false,
-            z_order: 67,
+            z_order: Z_ORDER_ANCHOR,
             spawn_scale: config.width,
             spawn_point: Vec3A::NEG_Z * 0.5,
             relative_to: RelativeTo::Stage,
diff --git a/src/overlays/keyboard.rs b/src/overlays/keyboard.rs
index f68910c..80a2fb3 100644
--- a/src/overlays/keyboard.rs
+++ b/src/overlays/keyboard.rs
@@ -40,7 +40,7 @@ fn send_key(app: &mut AppState, key: VirtualKey, down: bool) {
         {
             #[cfg(feature = "wayvr")]
             if let Some(wayvr) = &app.wayvr {
-                wayvr.borrow_mut().state.send_key(key as u32, down);
+                wayvr.borrow_mut().data.state.send_key(key as u32, down);
             }
         }
     }
diff --git a/src/overlays/toast.rs b/src/overlays/toast.rs
index 0cab820..e042ae3 100644
--- a/src/overlays/toast.rs
+++ b/src/overlays/toast.rs
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
 use crate::{
     backend::{
         common::OverlaySelector,
-        overlay::{OverlayBackend, OverlayState, RelativeTo},
+        overlay::{OverlayBackend, OverlayState, RelativeTo, Z_ORDER_TOAST},
         task::TaskType,
     },
     gui::{canvas::builder::CanvasBuilder, color_parse},
@@ -199,7 +199,7 @@ fn new_toast(toast: Toast, app: &mut AppState) -> Option<(OverlayState, Box<dyn
         spawn_scale: size.0 * PIXELS_TO_METERS,
         spawn_rotation,
         spawn_point,
-        z_order: 70,
+        z_order: Z_ORDER_TOAST,
         relative_to,
         ..Default::default()
     };
diff --git a/src/overlays/watch.rs b/src/overlays/watch.rs
index a28d847..eb770e7 100644
--- a/src/overlays/watch.rs
+++ b/src/overlays/watch.rs
@@ -1,7 +1,7 @@
 use glam::Vec3A;
 
 use crate::{
-    backend::overlay::{ui_transform, OverlayData, OverlayState, RelativeTo},
+    backend::overlay::{ui_transform, OverlayData, OverlayState, RelativeTo, Z_ORDER_WATCH},
     config::{load_known_yaml, ConfigType},
     gui::{
         canvas::Canvas,
@@ -25,7 +25,7 @@ where
             name: WATCH_NAME.into(),
             want_visible: true,
             interactable: true,
-            z_order: 68,
+            z_order: Z_ORDER_WATCH,
             spawn_scale: config.width,
             spawn_point: state.session.config.watch_pos,
             spawn_rotation: state.session.config.watch_rot,
diff --git a/src/overlays/wayvr.rs b/src/overlays/wayvr.rs
index 0dd0998..5085ed4 100644
--- a/src/overlays/wayvr.rs
+++ b/src/overlays/wayvr.rs
@@ -10,23 +10,33 @@ use crate::{
         input::{self, InteractionHandler},
         overlay::{
             ui_transform, FrameTransform, OverlayData, OverlayID, OverlayRenderer, OverlayState,
-            SplitOverlayBackend,
+            SplitOverlayBackend, Z_ORDER_DASHBOARD,
         },
         task::TaskType,
-        wayvr::{self, display, WayVR},
+        wayvr::{
+            self, display,
+            server_ipc::{gen_args_vec, gen_env_vec},
+            WayVR,
+        },
     },
+    config_wayvr,
     graphics::WlxGraphics,
     state::{self, AppState, KeyboardFocus},
 };
 
+// Hard-coded for now
+const DASHBOARD_WIDTH: u16 = 960;
+const DASHBOARD_HEIGHT: u16 = 540;
+const DASHBOARD_DISPLAY_NAME: &str = "_DASHBOARD";
+
 pub struct WayVRContext {
-    wayvr: Rc<RefCell<WayVRState>>,
+    wayvr: Rc<RefCell<WayVRData>>,
     display: wayvr::display::DisplayHandle,
 }
 
 impl WayVRContext {
     pub fn new(
-        wvr: Rc<RefCell<WayVRState>>,
+        wvr: Rc<RefCell<WayVRData>>,
         display: wayvr::display::DisplayHandle,
     ) -> anyhow::Result<Self> {
         Ok(Self {
@@ -36,18 +46,46 @@ impl WayVRContext {
     }
 }
 
-pub struct WayVRState {
-    pub display_handle_map: HashMap<display::DisplayHandle, OverlayID>,
-    pub state: WayVR,
+struct OverlayToCreate {
+    pub conf_display: config_wayvr::WayVRDisplay,
+    pub disp_handle: display::DisplayHandle,
+}
+
+pub struct WayVRData {
+    display_handle_map: HashMap<display::DisplayHandle, OverlayID>,
+    overlays_to_create: Vec<OverlayToCreate>,
+    pub data: WayVR,
 }
 
-impl WayVRState {
+impl WayVRData {
     pub fn new(config: wayvr::Config) -> anyhow::Result<Self> {
         Ok(Self {
             display_handle_map: Default::default(),
-            state: WayVR::new(config)?,
+            data: WayVR::new(config)?,
+            overlays_to_create: Vec::new(),
         })
     }
+
+    fn get_unique_display_name(&self, mut candidate: String) -> String {
+        let mut num = 0;
+
+        while !self
+            .data
+            .state
+            .displays
+            .vec
+            .iter()
+            .flatten()
+            .any(|d| d.obj.name == candidate)
+        {
+            if num > 0 {
+                candidate = format!("{} ({})", candidate, num);
+            }
+            num += 1;
+        }
+
+        candidate
+    }
 }
 
 pub struct WayVRInteractionHandler {
@@ -72,14 +110,15 @@ impl InteractionHandler for WayVRInteractionHandler {
     ) -> Option<input::Haptics> {
         let ctx = self.context.borrow();
 
-        let wayvr = &mut ctx.wayvr.borrow_mut().state;
-        if let Some(disp) = wayvr.displays.get(&ctx.display) {
+        let wayvr = &mut ctx.wayvr.borrow_mut().data;
+
+        if let Some(disp) = wayvr.state.displays.get(&ctx.display) {
             let pos = self.mouse_transform.transform_point2(hit.uv);
             let x = ((pos.x * disp.width as f32) as i32).max(0);
             let y = ((pos.y * disp.height as f32) as i32).max(0);
 
             let ctx = self.context.borrow();
-            wayvr.send_mouse_move(ctx.display, x as u32, y as u32);
+            wayvr.state.send_mouse_move(ctx.display, x as u32, y as u32);
         }
 
         None
@@ -100,11 +139,11 @@ impl InteractionHandler for WayVRInteractionHandler {
             }
         } {
             let ctx = self.context.borrow();
-            let wayvr = &mut ctx.wayvr.borrow_mut().state;
+            let wayvr = &mut ctx.wayvr.borrow_mut().data;
             if pressed {
-                wayvr.send_mouse_down(ctx.display, index);
+                wayvr.state.send_mouse_down(ctx.display, index);
             } else {
-                wayvr.send_mouse_up(ctx.display, index);
+                wayvr.state.send_mouse_up(ctx.display, index);
             }
         }
     }
@@ -113,6 +152,7 @@ impl InteractionHandler for WayVRInteractionHandler {
         let ctx = self.context.borrow();
         ctx.wayvr
             .borrow_mut()
+            .data
             .state
             .send_mouse_scroll(ctx.display, delta);
     }
@@ -127,8 +167,8 @@ pub struct WayVRRenderer {
 
 impl WayVRRenderer {
     pub fn new(
-        app: &mut state::AppState,
-        wvr: Rc<RefCell<WayVRState>>,
+        app: &state::AppState,
+        wvr: Rc<RefCell<WayVRData>>,
         display: wayvr::display::DisplayHandle,
     ) -> anyhow::Result<Self> {
         Ok(Self {
@@ -140,19 +180,13 @@ impl WayVRRenderer {
     }
 }
 
-fn get_or_create_display<O>(
+fn get_or_create_display_by_name(
     app: &mut AppState,
-    wayvr: &mut WayVRState,
+    wayvr: &mut WayVRData,
     disp_name: &str,
-) -> anyhow::Result<(display::DisplayHandle, Option<OverlayData<O>>)>
-where
-    O: Default,
-{
-    let created_overlay: Option<OverlayData<O>>;
-
+) -> anyhow::Result<display::DisplayHandle> {
     let disp_handle =
-        if let Some(disp) = WayVR::get_display_by_name(&wayvr.state.displays, disp_name) {
-            created_overlay = None;
+        if let Some(disp) = WayVR::get_display_by_name(&wayvr.data.state.displays, disp_name) {
             disp
         } else {
             let conf_display = app
@@ -165,117 +199,280 @@ where
                 ))?
                 .clone();
 
-            let disp_handle = wayvr.state.create_display(
+            let disp_handle = wayvr.data.state.create_display(
                 conf_display.width,
                 conf_display.height,
                 disp_name,
                 conf_display.primary.unwrap_or(false),
             )?;
 
-            let mut overlay = create_wayvr_display_overlay::<O>(
-                app,
-                conf_display.width,
-                conf_display.height,
+            wayvr.overlays_to_create.push(OverlayToCreate {
+                conf_display,
                 disp_handle,
-                conf_display.scale.unwrap_or(1.0),
-            )?;
+            });
 
+            disp_handle
+        };
+
+    Ok(disp_handle)
+}
+
+fn toggle_dashboard<O>(
+    app: &mut AppState,
+    overlays: &mut OverlayContainer<O>,
+    wayvr: &mut WayVRData,
+) -> anyhow::Result<()>
+where
+    O: Default,
+{
+    let (newly_created, disp_handle) = wayvr.data.state.get_or_create_dashboard_display(
+        DASHBOARD_WIDTH,
+        DASHBOARD_HEIGHT,
+        DASHBOARD_DISPLAY_NAME,
+    )?;
+
+    if newly_created {
+        log::info!("Creating dashboard overlay");
+
+        let mut overlay = create_overlay::<O>(
+            app,
+            wayvr,
+            DASHBOARD_DISPLAY_NAME,
+            OverlayToCreate {
+                disp_handle,
+                conf_display: config_wayvr::WayVRDisplay {
+                    attach_to: None,
+                    width: DASHBOARD_WIDTH,
+                    height: DASHBOARD_HEIGHT,
+                    scale: None,
+                    rotation: None,
+                    pos: None,
+                    primary: None,
+                },
+            },
+        )?;
+
+        overlay.state.want_visible = true;
+        overlay.state.spawn_scale = 1.25;
+        overlay.state.z_order = Z_ORDER_DASHBOARD;
+        overlay.state.reset(app, true);
+
+        // FIXME: overlay curvature needs to be dispatched for some unknown reason, this value is not set otherwise
+        app.tasks.enqueue(TaskType::Overlay(
+            OverlaySelector::Id(overlay.state.id),
+            Box::new(move |_app, o| {
+                o.curvature = Some(0.15);
+            }),
+        ));
+
+        overlays.add(overlay);
+
+        let conf_dash = &app.session.wayvr_config.dashboard;
+
+        let args_vec = match &conf_dash.args {
+            Some(args) => gen_args_vec(args),
+            None => vec![],
+        };
+
+        let env_vec = match &conf_dash.env {
+            Some(env) => gen_env_vec(env),
+            None => vec![],
+        };
+
+        // Start dashboard specified in the WayVR config
+        let _process_handle_unused =
             wayvr
-                .display_handle_map
-                .insert(disp_handle, overlay.state.id);
+                .data
+                .state
+                .spawn_process(disp_handle, &conf_dash.exec, &args_vec, &env_vec)?;
 
-            if let Some(attach_to) = &conf_display.attach_to {
-                overlay.state.relative_to = attach_to.get_relative_to();
-            }
+        return Ok(());
+    }
 
-            if let Some(rot) = &conf_display.rotation {
-                overlay.state.spawn_rotation = glam::Quat::from_axis_angle(
-                    Vec3::from_slice(&rot.axis),
-                    f32::to_radians(rot.angle),
-                );
-            }
+    let display = wayvr.data.state.displays.get(&disp_handle).unwrap(); // safe
+    let Some(overlay_id) = display.overlay_id else {
+        anyhow::bail!("Overlay ID not set for dashboard display");
+    };
 
-            if let Some(pos) = &conf_display.pos {
-                overlay.state.spawn_point = Vec3A::from_slice(pos);
+    app.tasks.enqueue(TaskType::Overlay(
+        OverlaySelector::Id(overlay_id),
+        Box::new(move |app, o| {
+            // Toggle visibility
+            o.want_visible = !o.want_visible;
+            if o.want_visible {
+                o.reset(app, true);
             }
+        }),
+    ));
 
-            let display = wayvr.state.displays.get_mut(&disp_handle).unwrap(); // Never fails
-            display.overlay_id = Some(overlay.state.id);
+    Ok(())
+}
 
-            created_overlay = Some(overlay);
+fn create_overlay<O>(
+    app: &mut AppState,
+    data: &mut WayVRData,
+    name: &str,
+    cell: OverlayToCreate,
+) -> anyhow::Result<OverlayData<O>>
+where
+    O: Default,
+{
+    let conf_display = &cell.conf_display;
+    let disp_handle = cell.disp_handle;
+
+    let mut overlay = create_wayvr_display_overlay::<O>(
+        app,
+        conf_display.width,
+        conf_display.height,
+        disp_handle,
+        conf_display.scale.unwrap_or(1.0),
+        name,
+    )?;
+
+    data.display_handle_map
+        .insert(disp_handle, overlay.state.id);
+
+    if let Some(attach_to) = &conf_display.attach_to {
+        overlay.state.relative_to = attach_to.get_relative_to();
+    }
 
-            disp_handle
+    if let Some(rot) = &conf_display.rotation {
+        overlay.state.spawn_rotation =
+            glam::Quat::from_axis_angle(Vec3::from_slice(&rot.axis), f32::to_radians(rot.angle));
+    }
+
+    if let Some(pos) = &conf_display.pos {
+        overlay.state.spawn_point = Vec3A::from_slice(pos);
+    }
+
+    let display = data.data.state.displays.get_mut(&disp_handle).unwrap(); // Never fails
+    display.overlay_id = Some(overlay.state.id);
+
+    Ok(overlay)
+}
+
+fn create_queued_displays<O>(
+    app: &mut AppState,
+    data: &mut WayVRData,
+    overlays: &mut OverlayContainer<O>,
+) -> anyhow::Result<()>
+where
+    O: Default,
+{
+    let overlays_to_create = std::mem::take(&mut data.overlays_to_create);
+
+    for cell in overlays_to_create {
+        let Some(disp) = data.data.state.displays.get(&cell.disp_handle) else {
+            continue; // this shouldn't happen
         };
 
-    Ok((disp_handle, created_overlay))
+        let name = disp.name.clone();
+
+        let overlay = create_overlay::<O>(app, data, name.as_str(), cell)?;
+        overlays.add(overlay); // Insert freshly created WayVR overlay into wlx stack
+    }
+
+    Ok(())
 }
 
 pub fn tick_events<O>(app: &mut AppState, overlays: &mut OverlayContainer<O>) -> anyhow::Result<()>
 where
     O: Default,
 {
-    if let Some(r_wayvr) = app.wayvr.clone() {
-        let mut wayvr = r_wayvr.borrow_mut();
-        while let Some(signal) = wayvr.state.signals.read() {
-            match signal {
-                wayvr::WayVRSignal::DisplayHideRequest(display_handle) => {
-                    if let Some(overlay_id) = wayvr.display_handle_map.get(&display_handle) {
-                        let overlay_id = *overlay_id;
-                        wayvr.state.set_display_visible(display_handle, false);
-                        app.tasks.enqueue(TaskType::Overlay(
-                            OverlaySelector::Id(overlay_id),
-                            Box::new(move |_app, o| {
-                                o.want_visible = false;
-                            }),
-                        ));
-                    }
+    let Some(r_wayvr) = app.wayvr.clone() else {
+        return Ok(());
+    };
+
+    let mut wayvr = r_wayvr.borrow_mut();
+
+    while let Some(signal) = wayvr.data.signals.read() {
+        match signal {
+            wayvr::WayVRSignal::DisplayHideRequest(display_handle) => {
+                if let Some(overlay_id) = wayvr.display_handle_map.get(&display_handle) {
+                    let overlay_id = *overlay_id;
+                    wayvr.data.state.set_display_visible(display_handle, false);
+                    app.tasks.enqueue(TaskType::Overlay(
+                        OverlaySelector::Id(overlay_id),
+                        Box::new(move |_app, o| {
+                            o.want_visible = false;
+                        }),
+                    ));
                 }
             }
         }
+    }
 
-        let res = wayvr.state.tick_events()?;
-        drop(wayvr);
-
-        for result in res {
-            match result {
-                wayvr::TickResult::NewExternalProcess(req) => {
-                    let config = &app.session.wayvr_config;
-
-                    let disp_name = if let Some(display_name) = req.env.display_name {
-                        config
-                            .get_display(display_name.as_str())
-                            .map(|_| display_name)
-                    } else {
-                        config
-                            .get_default_display()
-                            .map(|(display_name, _)| display_name)
-                    };
+    let res = wayvr.data.tick_events()?;
+    drop(wayvr);
+
+    for result in res {
+        match result {
+            wayvr::TickTask::NewExternalProcess(req) => {
+                let config = &app.session.wayvr_config;
+
+                let disp_name = if let Some(display_name) = req.env.display_name {
+                    config
+                        .get_display(display_name.as_str())
+                        .map(|_| display_name)
+                } else {
+                    config
+                        .get_default_display()
+                        .map(|(display_name, _)| display_name)
+                };
 
-                    if let Some(disp_name) = disp_name {
-                        let mut wayvr = r_wayvr.borrow_mut();
+                if let Some(disp_name) = disp_name {
+                    let mut wayvr = r_wayvr.borrow_mut();
 
-                        log::info!("Registering external process with PID {}", req.pid);
+                    log::info!("Registering external process with PID {}", req.pid);
 
-                        let (disp_handle, created_overlay) =
-                            get_or_create_display::<O>(app, &mut wayvr, &disp_name)?;
+                    let disp_handle = get_or_create_display_by_name(app, &mut wayvr, &disp_name)?;
 
-                        wayvr.state.add_external_process(disp_handle, req.pid);
+                    wayvr.data.state.add_external_process(disp_handle, req.pid);
 
-                        wayvr.state.manager.add_client(wayvr::client::WayVRClient {
+                    wayvr
+                        .data
+                        .state
+                        .manager
+                        .add_client(wayvr::client::WayVRClient {
                             client: req.client,
                             display_handle: disp_handle,
                             pid: req.pid,
                         });
-
-                        if let Some(created_overlay) = created_overlay {
-                            overlays.add(created_overlay);
-                        }
-                    }
                 }
             }
+            wayvr::TickTask::NewDisplay(cpar) => {
+                log::info!("Creating new display with name \"{}\"", cpar.name);
+
+                let mut wayvr = r_wayvr.borrow_mut();
+
+                let unique_name = wayvr.get_unique_display_name(cpar.name);
+
+                let disp_handle = wayvr.data.state.create_display(
+                    cpar.width,
+                    cpar.height,
+                    &unique_name,
+                    false,
+                )?;
+
+                wayvr.overlays_to_create.push(OverlayToCreate {
+                    disp_handle,
+                    conf_display: config_wayvr::WayVRDisplay {
+                        attach_to: Some(config_wayvr::AttachTo::from_packet(&cpar.attach_to)),
+                        width: cpar.width,
+                        height: cpar.height,
+                        pos: None,
+                        primary: None,
+                        rotation: None,
+                        scale: cpar.scale,
+                    },
+                });
+            }
         }
     }
 
+    let mut wayvr = r_wayvr.borrow_mut();
+    create_queued_displays(app, &mut wayvr, overlays)?;
+
     Ok(())
 }
 
@@ -290,11 +487,11 @@ impl WayVRRenderer {
 
             let ctx = self.context.borrow_mut();
             let wayvr = ctx.wayvr.borrow_mut();
-            if let Some(disp) = wayvr.state.displays.get(&ctx.display) {
+            if let Some(disp) = wayvr.data.state.displays.get(&ctx.display) {
                 let frame = DmabufFrame {
                     format: FrameFormat {
-                        width: disp.width,
-                        height: disp.height,
+                        width: disp.width as u32,
+                        height: disp.height as u32,
                         fourcc: FourCC {
                             value: data.mod_info.fourcc,
                         },
@@ -339,15 +536,15 @@ impl OverlayRenderer for WayVRRenderer {
 
     fn pause(&mut self, _app: &mut state::AppState) -> anyhow::Result<()> {
         let ctx = self.context.borrow_mut();
-        let wayvr = &mut ctx.wayvr.borrow_mut().state;
-        wayvr.set_display_visible(ctx.display, false);
+        let wayvr = &mut ctx.wayvr.borrow_mut().data;
+        wayvr.state.set_display_visible(ctx.display, false);
         Ok(())
     }
 
     fn resume(&mut self, _app: &mut state::AppState) -> anyhow::Result<()> {
         let ctx = self.context.borrow_mut();
-        let wayvr = &mut ctx.wayvr.borrow_mut().state;
-        wayvr.set_display_visible(ctx.display, true);
+        let wayvr = &mut ctx.wayvr.borrow_mut().data;
+        wayvr.state.set_display_visible(ctx.display, true);
         Ok(())
     }
 
@@ -355,9 +552,10 @@ impl OverlayRenderer for WayVRRenderer {
         let ctx = self.context.borrow();
         let mut wayvr = ctx.wayvr.borrow_mut();
 
-        wayvr.state.tick_display(ctx.display)?;
+        wayvr.data.tick_display(ctx.display)?;
 
         let dmabuf_data = wayvr
+            .data
             .state
             .get_dmabuf_data(ctx.display)
             .ok_or(anyhow::anyhow!("Failed to fetch dmabuf data"))?
@@ -385,18 +583,19 @@ impl OverlayRenderer for WayVRRenderer {
 #[allow(dead_code)]
 pub fn create_wayvr_display_overlay<O>(
     app: &mut state::AppState,
-    display_width: u32,
-    display_height: u32,
+    display_width: u16,
+    display_height: u16,
     display_handle: wayvr::display::DisplayHandle,
     display_scale: f32,
+    name: &str,
 ) -> anyhow::Result<OverlayData<O>>
 where
     O: Default,
 {
-    let transform = ui_transform(&[display_width, display_height]);
+    let transform = ui_transform(&[display_width as u32, display_height as u32]);
 
     let state = OverlayState {
-        name: format!("WayVR Screen ({}x{})", display_width, display_height).into(),
+        name: format!("WayVR - {}", name).into(),
         keyboard_focus: Some(KeyboardFocus::WayVR),
         want_visible: true,
         interactable: true,
@@ -440,20 +639,21 @@ pub enum WayVRAction {
         display_name: Arc<str>,
         action: WayVRDisplayClickAction,
     },
+    ToggleDashboard,
 }
 
-fn show_display<O>(wayvr: &mut WayVRState, overlays: &mut OverlayContainer<O>, display_name: &str)
+fn show_display<O>(wayvr: &mut WayVRData, overlays: &mut OverlayContainer<O>, display_name: &str)
 where
     O: Default,
 {
-    if let Some(display) = WayVR::get_display_by_name(&wayvr.state.displays, display_name) {
+    if let Some(display) = WayVR::get_display_by_name(&wayvr.data.state.displays, display_name) {
         if let Some(overlay_id) = wayvr.display_handle_map.get(&display) {
             if let Some(overlay) = overlays.mut_by_id(*overlay_id) {
                 overlay.state.want_visible = true;
             }
         }
 
-        wayvr.state.set_display_visible(display, true);
+        wayvr.data.state.set_display_visible(display, true);
     }
 }
 
@@ -462,7 +662,7 @@ fn action_app_click<O>(
     overlays: &mut OverlayContainer<O>,
     catalog_name: &Arc<str>,
     app_name: &Arc<str>,
-) -> anyhow::Result<Option<OverlayData<O>>>
+) -> anyhow::Result<()>
 where
     O: Default,
 {
@@ -481,46 +681,40 @@ where
     if let Some(app_entry) = catalog.get_app(app_name) {
         let mut wayvr = wayvr.borrow_mut();
 
-        let (disp_handle, created_overlay) =
-            get_or_create_display::<O>(app, &mut wayvr, &app_entry.target_display)?;
+        let disp_handle =
+            get_or_create_display_by_name(app, &mut wayvr, &app_entry.target_display)?;
 
-        // Parse additional args
-        let args_vec: Vec<&str> = if let Some(args) = &app_entry.args {
-            args.as_str().split_whitespace().collect()
-        } else {
-            vec![]
+        let args_vec = match &app_entry.args {
+            Some(args) => gen_args_vec(args),
+            None => vec![],
         };
 
-        // Parse additional env
-        let env_vec: Vec<(&str, &str)> = if let Some(env) = &app_entry.env {
-            // splits "FOO=BAR=TEST,123" into (&"foo", &"bar=test,123")
-            env.iter()
-                .filter_map(|e| e.as_str().split_once('='))
-                .collect()
-        } else {
-            vec![]
+        let env_vec = match &app_entry.env {
+            Some(env) => gen_env_vec(env),
+            None => vec![],
         };
 
         // Terminate existing process if required
         if let Some(process_handle) =
             wayvr
+                .data
                 .state
                 .process_query(disp_handle, &app_entry.exec, &args_vec, &env_vec)
         {
             // Terminate process
-            wayvr.state.terminate_process(process_handle);
+            wayvr.data.terminate_process(process_handle);
         } else {
             // Spawn process
             wayvr
+                .data
                 .state
                 .spawn_process(disp_handle, &app_entry.exec, &args_vec, &env_vec)?;
 
-            show_display(&mut wayvr, overlays, &app_entry.target_display.as_str());
+            show_display::<O>(&mut wayvr, overlays, app_entry.target_display.as_str());
         }
-        Ok(created_overlay)
-    } else {
-        Ok(None)
     }
+
+    Ok(())
 }
 
 pub fn action_display_click<O>(
@@ -535,23 +729,31 @@ where
     let wayvr = app.get_wayvr()?;
     let mut wayvr = wayvr.borrow_mut();
 
-    if let Some(handle) = WayVR::get_display_by_name(&wayvr.state.displays, display_name) {
-        if let Some(display) = wayvr.state.displays.get_mut(&handle) {
-            if let Some(overlay_id) = display.overlay_id {
-                if let Some(overlay) = overlays.mut_by_id(overlay_id) {
-                    match action {
-                        WayVRDisplayClickAction::ToggleVisibility => {
-                            // Toggle visibility
-                            overlay.state.want_visible = !overlay.state.want_visible;
-                        }
-                        WayVRDisplayClickAction::Reset => {
-                            // Show it at the front
-                            overlay.state.want_visible = true;
-                            overlay.state.reset(app, true);
-                        }
-                    }
-                }
-            }
+    let Some(handle) = WayVR::get_display_by_name(&wayvr.data.state.displays, display_name) else {
+        return Ok(());
+    };
+
+    let Some(display) = wayvr.data.state.displays.get_mut(&handle) else {
+        return Ok(());
+    };
+
+    let Some(overlay_id) = display.overlay_id else {
+        return Ok(());
+    };
+
+    let Some(overlay) = overlays.mut_by_id(overlay_id) else {
+        return Ok(());
+    };
+
+    match action {
+        WayVRDisplayClickAction::ToggleVisibility => {
+            // Toggle visibility
+            overlay.state.want_visible = !overlay.state.want_visible;
+        }
+        WayVRDisplayClickAction::Reset => {
+            // Show it at the front
+            overlay.state.want_visible = true;
+            overlay.state.reset(app, true);
         }
     }
 
@@ -567,17 +769,10 @@ where
             catalog_name,
             app_name,
         } => {
-            match action_app_click(app, overlays, catalog_name, app_name) {
-                Ok(res) => {
-                    if let Some(created_overlay) = res {
-                        overlays.add(created_overlay);
-                    }
-                }
-                Err(e) => {
-                    // Happens if something went wrong with initialization
-                    // or input exec path is invalid. Do nothing, just print an error
-                    log::error!("action_app_click failed: {}", e);
-                }
+            if let Err(e) = action_app_click(app, overlays, catalog_name, app_name) {
+                // Happens if something went wrong with initialization
+                // or input exec path is invalid. Do nothing, just print an error
+                log::error!("action_app_click failed: {}", e);
             }
         }
         WayVRAction::DisplayClick {
@@ -588,5 +783,13 @@ where
                 log::error!("action_display_click failed: {}", e);
             }
         }
+        WayVRAction::ToggleDashboard => {
+            let wayvr = app.get_wayvr().unwrap(); /* safe */
+            let mut wayvr = wayvr.borrow_mut();
+
+            if let Err(e) = toggle_dashboard::<O>(app, overlays, &mut wayvr) {
+                log::error!("toggle_dashboard failed: {}", e);
+            }
+        }
     }
 }
diff --git a/src/res/wayvr.yaml b/src/res/wayvr.yaml
index a3b378d..69309bb 100644
--- a/src/res/wayvr.yaml
+++ b/src/res/wayvr.yaml
@@ -5,8 +5,9 @@
 
 version: 1
 
-# Set to true if you want to make Wyland server instantly available
-# (used for remote starting external processes)
+# Set to true if you want to make Wyland server instantly available.
+# By default, WayVR starts only when it's needed.
+# (this option is primarily used for remote starting external processes and development purposes)
 run_compositor_at_start: false 
 
 # Automatically close overlays with zero window count?
@@ -22,6 +23,17 @@ keyboard_repeat_delay: 200
 # Chars per second
 keyboard_repeat_rate: 50
 
+# WayVR-compatible dashboard.
+# For now, there is only one kind of dashboard with WayVR IPC support (WayVR Dashboard).
+# Build instructions: https://github.com/olekolek1000/wayvr-dashboard
+# exec: Executable path, for example /home/USER/wayvr_dashboard/src-tauri/target/release/wayvr_dashboard
+# GDK_BACKEND=wayland: Force-use Wayland for GTK apps
+# LIBGL_ALWAYS_SOFTWARE: Mesa crash mitigation for Tauri apps on AMD GPUs
+dashboard:
+  exec: "wayvr_dashboard"
+  args: ""
+  env: ["GDK_BACKEND=wayland", "LIBGL_ALWAYS_SOFTWARE=1"]
+
 displays:
   Watch:
     width: 400
diff --git a/src/state.rs b/src/state.rs
index b3c4cb2..67b268f 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -10,7 +10,7 @@ use vulkano::image::view::ImageView;
 #[cfg(feature = "wayvr")]
 use {
     crate::config_wayvr::{self, WayVRConfig},
-    crate::overlays::wayvr::WayVRState,
+    crate::overlays::wayvr::WayVRData,
     std::{cell::RefCell, rc::Rc},
 };
 
@@ -50,7 +50,7 @@ pub struct AppState {
     pub keyboard_focus: KeyboardFocus,
 
     #[cfg(feature = "wayvr")]
-    pub wayvr: Option<Rc<RefCell<WayVRState>>>, // Dynamically created if requested
+    pub wayvr: Option<Rc<RefCell<WayVRData>>>, // Dynamically created if requested
 }
 
 impl AppState {
@@ -122,11 +122,11 @@ impl AppState {
 
     #[cfg(feature = "wayvr")]
     #[allow(dead_code)]
-    pub fn get_wayvr(&mut self) -> anyhow::Result<Rc<RefCell<WayVRState>>> {
+    pub fn get_wayvr(&mut self) -> anyhow::Result<Rc<RefCell<WayVRData>>> {
         if let Some(wvr) = &self.wayvr {
             Ok(wvr.clone())
         } else {
-            let wayvr = Rc::new(RefCell::new(WayVRState::new(
+            let wayvr = Rc::new(RefCell::new(WayVRData::new(
                 WayVRConfig::get_wayvr_config(&self.session.config, &self.session.wayvr_config),
             )?));
             self.wayvr = Some(wayvr.clone());