diff --git a/Cargo.lock b/Cargo.lock
index 54bef4444..a3db6573b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2139,7 +2139,7 @@ version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ad46a0e6c9bc688823a742aa969b5c08fdc56c2a436ee00d5c6fbcb5982c55c4"
 dependencies = [
- "libm",
+ "libm 0.2.8",
 ]
 
 [[package]]
@@ -3493,6 +3493,12 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "libm"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fc7aa29613bd6a620df431842069224d8bc9011086b1db4c0e0cd47fa03ec9a"
+
 [[package]]
 name = "libm"
 version = "0.2.8"
@@ -3813,6 +3819,22 @@ dependencies = [
  "tempfile",
 ]
 
+[[package]]
+name = "native-windows-gui"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454"
+dependencies = [
+ "bitflags 1.3.2",
+ "lazy_static",
+ "newline-converter",
+ "plotters",
+ "plotters-backend",
+ "stretch",
+ "winapi 0.3.9",
+ "winapi-build",
+]
+
 [[package]]
 name = "ndk"
 version = "0.7.0"
@@ -3891,6 +3913,15 @@ dependencies = [
  "log",
 ]
 
+[[package]]
+name = "newline-converter"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f"
+dependencies = [
+ "unicode-segmentation",
+]
+
 [[package]]
 name = "nix"
 version = "0.23.2"
@@ -4574,6 +4605,24 @@ dependencies = [
  "time 0.3.30",
 ]
 
+[[package]]
+name = "plotters"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15b6eccb8484002195a3e44fe65a4ce8e93a625797a063735536fd59cb01cf3"
+dependencies = [
+ "num-traits 0.2.17",
+ "plotters-backend",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "414cec62c6634ae900ea1c56128dfe87cf63e7caece0852ec76aba307cebadb7"
+
 [[package]]
 name = "png"
 version = "0.17.10"
@@ -5390,6 +5439,7 @@ dependencies = [
  "brotli",
  "dirs 5.0.1",
  "md5",
+ "native-windows-gui",
  "winapi 0.3.9",
  "winres",
 ]
@@ -5948,6 +5998,16 @@ version = "0.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
 
+[[package]]
+name = "stretch"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b0dc6d20ce137f302edf90f9cd3d278866fd7fb139efca6f246161222ad6d87"
+dependencies = [
+ "lazy_static",
+ "libm 0.1.4",
+]
+
 [[package]]
 name = "strsim"
 version = "0.8.0"
diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml
index e39762a30..d0305b6b0 100644
--- a/libs/portable/Cargo.toml
+++ b/libs/portable/Cargo.toml
@@ -14,6 +14,9 @@ dirs = "5.0"
 md5 = "0.7"
 winapi = { version = "0.3", features = ["winbase"] }
 
+[target.'cfg(target_os = "windows")'.dependencies]
+native-windows-gui = "1.0"
+
 [package.metadata.winres]
 LegalCopyright = "Copyright © 2024 Purslane Ltd. All rights reserved."
 ProductName = "RustDesk"
diff --git a/libs/portable/src/main.rs b/libs/portable/src/main.rs
index 1ffc8aa5d..48372d68b 100644
--- a/libs/portable/src/main.rs
+++ b/libs/portable/src/main.rs
@@ -8,6 +8,8 @@ use std::{
 use bin_reader::BinaryReader;
 
 pub mod bin_reader;
+#[cfg(windows)]
+mod ui;
 
 #[cfg(windows)]
 const APP_METADATA: &[u8] = include_bytes!("../app_metadata.toml");
@@ -119,6 +121,11 @@ fn main() {
     let click_setup = args.is_empty() && arg_exe.to_lowercase().ends_with("install.exe");
     let quick_support = args.is_empty() && arg_exe.to_lowercase().ends_with("qs.exe");
 
+    #[cfg(windows)]
+    if args.is_empty() {
+        ui::setup();
+    }
+
     let reader = BinaryReader::default();
     if let Some(exe) = setup(
         reader,
diff --git a/libs/portable/src/res/label.png b/libs/portable/src/res/label.png
new file mode 100644
index 000000000..6876c7935
Binary files /dev/null and b/libs/portable/src/res/label.png differ
diff --git a/libs/portable/src/res/spin.gif b/libs/portable/src/res/spin.gif
new file mode 100644
index 000000000..44b2e2e62
Binary files /dev/null and b/libs/portable/src/res/spin.gif differ
diff --git a/libs/portable/src/ui.rs b/libs/portable/src/ui.rs
new file mode 100644
index 000000000..0a015a248
--- /dev/null
+++ b/libs/portable/src/ui.rs
@@ -0,0 +1,232 @@
+use native_windows_gui as nwg;
+use nwg::NativeUi;
+use std::cell::RefCell;
+
+const GIF_DATA: &[u8] = include_bytes!("./res/spin.gif");
+const LABEL_DATA: &[u8] = include_bytes!("./res/label.png");
+const GIF_SIZE: i32 = 32;
+const BG_COLOR: [u8; 3] = [90, 90, 120];
+const BORDER_COLOR: [u8; 3] = [40, 40, 40];
+const GIF_DELAY: u64 = 30;
+
+#[derive(Default)]
+pub struct BasicApp {
+    window: nwg::Window,
+
+    border_image: nwg::ImageFrame,
+    bg_image: nwg::ImageFrame,
+    gif_image: nwg::ImageFrame,
+    label_image: nwg::ImageFrame,
+
+    border_layout: nwg::GridLayout,
+    bg_layout: nwg::GridLayout,
+    inner_layout: nwg::GridLayout,
+
+    timer: nwg::AnimationTimer,
+    decoder: nwg::ImageDecoder,
+    gif_index: RefCell<usize>,
+    gif_images: RefCell<Vec<nwg::Bitmap>>,
+}
+
+impl BasicApp {
+    fn exit(&self) {
+        self.timer.stop();
+        nwg::stop_thread_dispatch();
+    }
+
+    fn load_gif(&self) -> Result<(), nwg::NwgError> {
+        let image_source = self.decoder.from_stream(GIF_DATA)?;
+        for frame_index in 0..image_source.frame_count() {
+            let image_data = image_source.frame(frame_index)?;
+            let image_data = self
+                .decoder
+                .resize_image(&image_data, [GIF_SIZE as u32, GIF_SIZE as u32])?;
+            let bmp = image_data.as_bitmap()?;
+            self.gif_images.borrow_mut().push(bmp);
+        }
+        Ok(())
+    }
+
+    fn update_gif(&self) -> Result<(), nwg::NwgError> {
+        let images = self.gif_images.borrow();
+        if images.len() == 0 {
+            return Err(nwg::NwgError::ImageDecoderError(
+                -1,
+                "no gif images".to_string(),
+            ));
+        }
+        let image_index = *self.gif_index.borrow() % images.len();
+        self.gif_image.set_bitmap(Some(&images[image_index]));
+        *self.gif_index.borrow_mut() = (image_index + 1) % images.len();
+        Ok(())
+    }
+
+    fn start_timer(&self) {
+        self.timer.start();
+    }
+}
+
+mod basic_app_ui {
+    use super::*;
+    use native_windows_gui::{self as nwg, Bitmap};
+    use nwg::{Event, GridLayoutItem};
+    use std::cell::RefCell;
+    use std::ops::Deref;
+    use std::rc::Rc;
+
+    pub struct BasicAppUi {
+        inner: Rc<BasicApp>,
+        default_handler: RefCell<Vec<nwg::EventHandler>>,
+    }
+
+    impl nwg::NativeUi<BasicAppUi> for BasicApp {
+        fn build_ui(mut data: BasicApp) -> Result<BasicAppUi, nwg::NwgError> {
+            data.decoder = nwg::ImageDecoder::new()?;
+            let col_cnt: i32 = 7;
+            let row_cnt: i32 = 3;
+            let border_width: i32 = 1;
+            let window_size = (
+                GIF_SIZE * col_cnt + 2 * border_width,
+                GIF_SIZE * row_cnt + 2 * border_width,
+            );
+
+            // Controls
+            nwg::Window::builder()
+                .flags(nwg::WindowFlags::POPUP | nwg::WindowFlags::VISIBLE)
+                .size(window_size)
+                .center(true)
+                .build(&mut data.window)?;
+
+            nwg::ImageFrame::builder()
+                .parent(&data.window)
+                .size(window_size)
+                .background_color(Some(BORDER_COLOR))
+                .build(&mut data.border_image)?;
+
+            nwg::ImageFrame::builder()
+                .parent(&data.border_image)
+                .size((row_cnt * GIF_SIZE, col_cnt * GIF_SIZE))
+                .background_color(Some(BG_COLOR))
+                .build(&mut data.bg_image)?;
+
+            nwg::ImageFrame::builder()
+                .parent(&data.bg_image)
+                .size((GIF_SIZE, GIF_SIZE))
+                .background_color(Some(BG_COLOR))
+                .build(&mut data.gif_image)?;
+
+            nwg::ImageFrame::builder()
+                .parent(&data.bg_image)
+                .background_color(Some(BG_COLOR))
+                .bitmap(Some(&Bitmap::from_bin(LABEL_DATA)?))
+                .build(&mut data.label_image)?;
+
+            nwg::AnimationTimer::builder()
+                .parent(&data.window)
+                .interval(std::time::Duration::from_millis(GIF_DELAY))
+                .build(&mut data.timer)?;
+
+            // Wrap-up
+            let ui = BasicAppUi {
+                inner: Rc::new(data),
+                default_handler: Default::default(),
+            };
+
+            // Layouts
+            nwg::GridLayout::builder()
+                .parent(&ui.window)
+                .spacing(0)
+                .margin([0, 0, 0, 0])
+                .max_column(Some(1))
+                .max_row(Some(1))
+                .child_item(GridLayoutItem::new(&ui.border_image, 0, 0, 1, 1))
+                .build(&ui.border_layout)?;
+
+            nwg::GridLayout::builder()
+                .parent(&ui.border_image)
+                .spacing(0)
+                .margin([
+                    border_width as _,
+                    border_width as _,
+                    border_width as _,
+                    border_width as _,
+                ])
+                .max_column(Some(1))
+                .max_row(Some(1))
+                .child_item(GridLayoutItem::new(&ui.bg_image, 0, 0, 1, 1))
+                .build(&ui.bg_layout)?;
+
+            nwg::GridLayout::builder()
+                .parent(&ui.bg_image)
+                .spacing(0)
+                .margin([0, 0, 0, 0])
+                .max_column(Some(col_cnt as _))
+                .max_row(Some(row_cnt as _))
+                .child_item(GridLayoutItem::new(&ui.gif_image, 2, 1, 1, 1))
+                .child_item(GridLayoutItem::new(&ui.label_image, 3, 1, 3, 1))
+                .build(&ui.inner_layout)?;
+
+            // Events
+            let evt_ui = Rc::downgrade(&ui.inner);
+            let handle_events = move |evt, _evt_data, _handle| {
+                if let Some(evt_ui) = evt_ui.upgrade().as_mut() {
+                    match evt {
+                        Event::OnWindowClose => {
+                            evt_ui.exit();
+                        }
+                        Event::OnTimerTick => {
+                            if let Err(e) = evt_ui.update_gif() {
+                                eprintln!("{:?}", e);
+                            }
+                        }
+                        _ => {}
+                    }
+                }
+            };
+
+            ui.default_handler
+                .borrow_mut()
+                .push(nwg::full_bind_event_handler(
+                    &ui.window.handle,
+                    handle_events,
+                ));
+
+            return Ok(ui);
+        }
+    }
+
+    impl Drop for BasicAppUi {
+        /// To make sure that everything is freed without issues, the default handler must be unbound.
+        fn drop(&mut self) {
+            let mut handlers = self.default_handler.borrow_mut();
+            for handler in handlers.drain(0..) {
+                nwg::unbind_event_handler(&handler);
+            }
+        }
+    }
+
+    impl Deref for BasicAppUi {
+        type Target = BasicApp;
+
+        fn deref(&self) -> &BasicApp {
+            &self.inner
+        }
+    }
+}
+
+fn ui() -> Result<(), nwg::NwgError> {
+    nwg::init()?;
+    let app = BasicApp::build_ui(Default::default())?;
+    app.load_gif()?;
+    app.start_timer();
+    nwg::dispatch_thread_events();
+    Ok(())
+}
+
+pub fn setup() {
+    std::thread::spawn(move || {
+        if let Err(e) = ui() {
+            eprintln!("{:?}", e);
+        }
+    });
+}