Skip to content

Qt App Launcher

Time estimate: ~90 minutes (including host development) Prerequisites: Qt + EGLFS Dashboard

Learning Objectives

By the end of this tutorial you will be able to:

  • Build a swipeable multi-page launcher UI using Qt Quick's SwipeView
  • Launch child applications from QML using QProcess with DRM master handoff
  • Read system information (CPU, memory, temperature) from procfs and sysfs
  • Create a reusable QML component (AppButton) with custom properties and signals
  • Wire a physical GPIO button and use libgpiod to create a home-button daemon
  • Run the launcher and home button as systemd kiosk services
From Dashboard to Launcher

The Qt Dashboard is a single-purpose display application. A real embedded device often runs multiple applications — a dashboard, a game, a configuration screen. On a desktop system, a window manager handles this. On an EGLFS embedded device, there is no window manager: only one application can own the display at a time. A launcher solves this by:

  1. Displaying a home screen with app buttons (like a car infotainment system)
  2. Hiding its window when launching a child — releasing DRM master so the child can render
  3. Reclaiming the display when the child exits

Qt's QProcess wraps fork/exec with event loop integration: the finished() signal fires when the child exits (via waitpid), without blocking the launcher. Compare this to the ~80 lines of manual evdev swipe detection in SDL2 — SwipeView gives you velocity-based gesture handling and snap-to-page for free.

See also: Qt Quick for Embedded Linux for a reference on QML concepts, property bindings, and the EGLFS platform plugin.


Overview

main.cpp (C++):
  LaunchManager (QObject)
    ├── launch(command) → hide window → QProcess → show window on exit
    ├── writes child PID → /tmp/launcher_child.pid
    └── Q_PROPERTY: childRunning
  SystemInfo (QObject)
    ├── QTimer @ 0.5 Hz → reads /proc/stat, /proc/meminfo, sysfs
    └── Q_PROPERTY: cpuPercent, memUsedMB, temperature, uptime, ipAddress
         │  changed signals (automatic)
Main.qml (QML):
  Window { FullScreen, EGLFS }
    ├── Header { hostname, IP }
    ├── SwipeView {
    │     Page 1: Apps          — Doom, Dashboard, Ball Balance, Pong
    │     Page 2: Demos         — 3D Cube, Touch Paint, Level, SDL2 Dashboard, ...
    │     Page 3: System info   ← bindings to sysinfo.*
    │   }
    └── PageIndicator { dots }

home_button.py (separate process):
  gpiod edge detect → read PID file → SIGTERM → child exits → launcher reclaims display

1. Copy the Source Files

The launcher source is provided in the course repository:

ls ~/embedded-linux/apps/qt_launcher/

You should see:

CMakeLists.txt  main.cpp  Main.qml  AppButton.qml  home_button.py
Checkpoint

All 4 files present in qt_launcher/.


2. Understand the Architecture

2.1 CMakeLists.txt

Open the build file:

cat ~/embedded-linux/apps/qt_launcher/CMakeLists.txt

This follows the same pattern as the dashboard: qt_add_executable creates the binary, qt_add_qml_module embeds the QML files with a module URI (Launcher). The engine loads QML via engine.loadFromModule("Launcher", "Main") — no file paths needed at runtime.

2.2 LaunchManager — DRM Master Handoff

The key challenge: on EGLFS, only one process can hold DRM master (the GPU rendering authority). When the launcher starts a child app, it must release the display first.

Open main.cpp and find the LaunchManager class. The critical sequence in launch():

/* Release the display so the child can claim DRM master */
hideWindow();
dropDrmMaster();

m_process = new QProcess(this);
connect(m_process, &QProcess::finished, this, [this](...) {
    m_process->deleteLater();
    acquireDrmMaster();  /* Reclaim GPU */
    showWindow();        /* Show launcher again */
});

m_process->start("/bin/sh", {"-c", command});

Hiding windows alone is not enough — EGLFS holds the DRM file descriptor internally and does not release master status when windows are hidden. Without explicitly dropping master, child apps fail with Could not queue DRM page flip (Permission denied).

How dropDrmMaster() finds the EGLFS fd

DRM master is tracked per open file descriptor, not per process. We cannot access EGLFS's internal fd through public Qt API, so we scan /proc/self/fd/ to find every fd that points to /dev/dri/card* and call drmDropMaster() on it. We do not close the fd — it belongs to EGLFS. When the child exits, acquireDrmMaster() calls drmSetMaster() on the same fds to reclaim GPU control.

Why QProcess?

QProcess wraps fork() + exec() with Qt event loop integration. The finished() signal fires asynchronously when the child terminates — the launcher's event loop keeps running (processing the childRunning property, etc.) without blocking. Compare this to system() which blocks the entire thread.

2.3 SystemInfo — procfs and sysfs

The SystemInfo class reads:

Source Data Update
/proc/stat CPU usage (delta between reads) 2 s
/proc/meminfo MemTotal, MemAvailable 2 s
/sys/class/thermal/thermal_zone0/temp CPU temperature (millidegrees) 2 s
/proc/uptime Seconds since boot 2 s
QNetworkInterface First non-loopback IPv4 address 2 s

These are the same sysfs/procfs paths used throughout the course — the only difference is reading them from C++ instead of Python or shell scripts.

2.4 SwipeView and AppButton

Open Main.qml. The UI uses SwipeView — a built-in Qt Quick component that handles swipe gestures with velocity tracking, deceleration, and snap-to-page:

SwipeView {
    id: swipe
    /* Page 1: Apps — games and interactive programs */
    Item {
        GridLayout {
            AppButton { label: "Doom"; onClicked: launcher.launch("...") }
            AppButton { label: "Dashboard"; onClicked: launcher.launch(repo + "/apps/...") }
        }
    }
    /* Page 2: Demos — SDL2 tutorial solutions */
    Item {
        GridLayout {
            AppButton { label: "3D Cube"; onClicked: launcher.launch("SDL_VIDEODRIVER=kmsdrm " + repo + "/solutions/...") }
            AppButton { label: "Touch Paint"; onClicked: launcher.launch("...") }
        }
    }
    /* Page 3: System Info */
    Item { /* CPU, memory, temperature, uptime, IP */ }
}

All app paths use a repo property (/home/linux/embedded-linux) so students only need to clone the repo to their home directory. SDL2 apps get SDL_VIDEODRIVER=kmsdrm prefix.

PageIndicator automatically tracks which page is visible. No manual state management needed. The header bar also shows the current page name ("Apps", "Demos", "System").

The AppButton.qml component is a reusable rectangle with: - property string icon — Unicode character displayed large - property string label — text below the icon - property color accent — border and icon color - signal clicked() — emitted by internal MouseArea

Checkpoint

You can explain the DRM handoff sequence and why QProcess::finished is non-blocking.


3. Build on Host

Install Qt6 dependencies (if not already installed from the dashboard tutorial):

# Debian/Ubuntu
sudo apt install qt6-base-dev qt6-declarative-dev \
    qml6-module-qtquick qml6-module-qtquick-layouts \
    qml6-module-qtquick-controls qml6-module-qtquick-window \
    libdrm-dev cmake g++

# Arch
sudo pacman -S qt6-base qt6-declarative libdrm cmake

Build and run:

cd ~/embedded-linux/apps/qt_launcher
cmake -B build
cmake --build build
QT_QPA_PLATFORM=xcb ./build/qt_launcher

You should see a dark fullscreen window (press Escape to exit on desktop) with: - Header bar showing hostname, IP, and current page name - Page 1: grid of app buttons (Doom, Dashboard, Ball Balance, Pong) - Page 2: SDL2 demo buttons (3D Cube, Touch Paint, Level, etc.) - Page 3: system info (CPU, memory, temperature, uptime, IP) - Swipe left/right (or drag) to navigate between pages - Dots at the bottom indicating the current page

Desktop Testing

On desktop, the app buttons will try to launch Pi paths (/home/linux/embedded-linux/...) which don't exist. That's fine — the launcher will report the child exited immediately. The point is to test the UI layout, swipe gesture, and system info display.

Checkpoint

Launcher runs on host with swipeable pages, buttons are tappable, system info shows real host data.


4. Deploy to Pi

Clone the repo (or pull if already cloned) and build everything:

ssh linux@<PI_IP>

# Clone the course repo (once)
cd ~
git clone https://github.com/OE-KVK-H2IoT/embedded-linux.git

# Build the launcher
cd ~/embedded-linux/apps/qt_launcher
cmake -B build
cmake --build build

# Build SDL2 apps (level, dashboard, pong)
cd ~/embedded-linux/apps
make

# Build SDL2 tutorial solutions (cube, touch paint)
cd ~/embedded-linux/solutions/sdl2-rotating-cube
cmake -B build && cmake --build build

cd ~/embedded-linux/solutions/sdl2-touch-paint
cmake -B build && cmake --build build

Run directly:

sudo QT_QPA_PLATFORM=eglfs ./build/qt_launcher
No other display app can be running

EGLFS requires exclusive DRM master. Stop any running display services first:

sudo systemctl stop 'level-*.service' 'sdl2-*.service' 'doom-*.service' 2>/dev/null

Swipe between pages using the touchscreen. Tap an app button — if the target app is installed, it will take over the display. When you close it (or it crashes), the launcher reappears.

Checkpoint

Launcher runs fullscreen on Pi. At least one child app (Dashboard or Level) launches and returns to the launcher on exit.


5. Customize the App Grid

Edit Main.qml to match the apps you've actually built. The launcher.launch() command is passed to /bin/sh -c, so any shell command works:

AppButton {
    icon: "\u2694"
    label: "Doom"
    accent: "#e05050"
    onClicked: launcher.launch("chocolate-doom -iwad /home/linux/doom1.wad")
}

All paths use the repo property (/home/linux/embedded-linux). Common app commands:

App Page Command
Chocolate Doom Apps chocolate-doom -iwad /home/linux/doom1.wad
Qt Dashboard Apps $REPO/apps/qt_dashboard/build/qt_dashboard
Pong Apps $REPO/apps/pong_fb
Ball Detection Apps python3 /home/linux/ball_detect.py
3D Cube Demos SDL_VIDEODRIVER=kmsdrm $REPO/solutions/sdl2-rotating-cube/build/step4_cube
Touch Paint Demos SDL_VIDEODRIVER=kmsdrm $REPO/solutions/sdl2-touch-paint/build/sdl2_touch_paint
Level Display Demos SDL_VIDEODRIVER=kmsdrm $REPO/apps/level_sdl2
SDL2 Dashboard Demos SDL_VIDEODRIVER=kmsdrm $REPO/apps/sdl2_dashboard
Adding More Pages

To add a fourth page, add another Item { ... } inside the SwipeView. The PageIndicator adapts automatically — its count is bound to swipe.count.


6. Systemd Kiosk Service

Create a service file so the launcher starts at boot:

sudo tee /etc/systemd/system/qt-launcher.service << 'EOF'
[Unit]
Description=Qt App Launcher (EGLFS)
After=multi-user.target
Conflicts=level-sdl2.service sdl2-dashboard.service doom-kiosk.service

[Service]
Type=simple
ExecStart=/home/linux/embedded-linux/apps/qt_launcher/build/qt_launcher
Environment=QT_QPA_PLATFORM=eglfs
Restart=on-failure
RestartSec=3
User=root
StandardOutput=journal
StandardError=journal

# Suppress text console
ExecStartPre=/bin/sh -c 'echo 0 > /sys/class/vtconsole/vtcon1/bind'

[Install]
WantedBy=multi-user.target
EOF

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable qt-launcher.service
sudo systemctl start qt-launcher.service

Check status:

sudo systemctl status qt-launcher.service
sudo journalctl -u qt-launcher.service -f
Checkpoint

Launcher starts automatically at boot. Launching a child app works from the touchscreen. Returning to the launcher works after the child exits.


7. GPIO Home Button

When a child app takes over the display, there is no way to get back to the launcher — the child owns the entire screen, and the launcher has no window visible. On a phone, you press the Home button. We will build the same thing with a GPIO pushbutton and libgpiod.

GPIO on Linux: sysfs vs libgpiod

Early Linux GPIO access used /sys/class/gpio/ (write 17 to export, then read/write value). This sysfs interface is deprecated since Linux 4.8.

The modern interface is libgpiod (/dev/gpiochipN), which provides:

Feature sysfs (deprecated) libgpiod (modern)
Edge detection Poll /sys/class/gpio/gpioN/value Kernel-level interrupt, wait_edge_events() blocks
Bias (pull-up/down) Not supported Bias.PULL_UP, Bias.PULL_DOWN
Multiple consumers Conflicts silently Named consumers, proper resource management
Release on exit Manual unexport Automatic on file descriptor close

Always use libgpiod for new projects. The Python bindings (python3-gpiod) and CLI tools (gpiod package) are available in all distributions.

7.1 Wire the Button

Connect a pushbutton between GPIO17 (header pin 11) and GND (header pin 9):

  Raspberry Pi Header (left side, top)
  ┌─────────┐
  │ 1  3V3  │
  │ 3  SDA  │
  │ 5  SCL  │
  │ 7  GP4  │
  │ 9  GND ─┤──── button ────┤
  │11  GP17 ┤────────────────┘
  │13  GP27 │
  └─────────┘

No external resistor needed — libgpiod enables the SoC's internal pull-up. The line reads HIGH when idle, LOW when the button is pressed (active-low, falling edge).

7.2 Test with CLI Tools

Install libgpiod tools and verify the button works:

sudo apt install gpiod python3-gpiod

Read the current value (should be 1 with button released):

# Pi 5: gpiochip4, Pi 4: gpiochip0
gpioget --bias=pull-up /dev/gpiochip4 17

Monitor edges — press the button and see events:

gpiomon --bias=pull-up --falling-edge /dev/gpiochip4 17

Press the button a few times. Each press prints a line with a timestamp. Press Ctrl+C to stop.

Checkpoint

gpioget shows 1 (idle) and gpiomon prints events on button press.

7.3 The Home Button Daemon

The launcher already writes the child's PID to /tmp/launcher_child.pid when it starts a child app (and removes it when the child exits). The home button daemon monitors the GPIO and sends SIGTERM to that PID.

IPC Pattern: PID File + Signal

This is a classic Unix IPC (Inter-Process Communication) pattern:

  1. Writer (launcher) creates /tmp/launcher_child.pid containing the child's PID
  2. Reader (home button daemon) reads the PID when the button is pressed
  3. Communication happens via os.kill(pid, SIGTERM) — a Unix signal

No shared memory, no sockets, no pipes — just a file and a signal. This pattern is used by nginx, sshd, and most system daemons (check /var/run/*.pid).

Compare to the IPC mechanisms from Processes and IPC: signals are one-way notifications. The child doesn't need to know who sent the signal — it just receives SIGTERM and exits cleanly (or gets SIGKILL if it ignores it).

Open the daemon script:

cat ~/embedded-linux/apps/qt_launcher/home_button.py

The key parts:

# Request GPIO line with pull-up and falling-edge detection
request = gpiod.request_lines(
    "/dev/gpiochip4",
    consumer="home-button",
    config={
        17: gpiod.LineSettings(
            direction=gpiod.line.Direction.INPUT,
            bias=gpiod.line.Bias.PULL_UP,
            edge_detection=gpiod.line.Edge.FALLING,
        )
    },
)

# Block until button press (no CPU-wasting poll loop)
while True:
    if request.wait_edge_events(timeout=None):
        events = request.read_edge_events()
        kill_child()  # reads PID file, sends SIGTERM

wait_edge_events() is interrupt-driven — the process sleeps in the kernel until the GPIO pin changes. This uses zero CPU while waiting, unlike polling in a loop.

Test it manually (with a child app running from the launcher):

sudo python3 ~/embedded-linux/apps/qt_launcher/home_button.py

Press the button. The child app should exit and the launcher should reappear.

Pi 4 vs Pi 5

Pi 4 uses /dev/gpiochip0, Pi 5 uses /dev/gpiochip4. Pass --chip /dev/gpiochip0 on Pi 4:

sudo python3 home_button.py --chip /dev/gpiochip0

Checkpoint

Pressing the GPIO button while a child app is running returns to the launcher.

7.4 Home Button Systemd Service

Create a service so the button daemon runs automatically:

sudo tee /etc/systemd/system/home-button.service << 'EOF'
[Unit]
Description=GPIO Home Button (kills active child app)
After=qt-launcher.service

[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/linux/embedded-linux/apps/qt_launcher/home_button.py
Restart=on-failure
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

Enable and start:

sudo systemctl daemon-reload
sudo systemctl enable home-button.service
sudo systemctl start home-button.service

Now the complete flow works automatically at boot: launcher starts → user taps app → child runs → press button → child exits → launcher reappears.

Checkpoint

Home button service runs at boot. Full cycle works: launcher → app → button press → launcher.


8. What Just Happened?

You built an embedded app launcher with a physical home button:

  1. EGLFS rendering — direct GPU, no compositor overhead
  2. SwipeView — gesture-based page switching with zero custom touch code
  3. DRM master handoff — hides window to release GPU, reclaims on child exit
  4. procfs/sysfs — the same /proc and /sys files used throughout the course, now from C++
  5. GPIO home buttonlibgpiod edge detection, interrupt-driven (zero CPU while waiting)
  6. PID file + signal IPC — launcher writes PID, daemon reads it and sends SIGTERM
  7. Two systemd services — launcher + home button, both auto-start at boot
               ┌───────────────┐
               │   Qt Launcher │        ┌──────────────────┐
               │   (EGLFS)     │        │  home_button.py  │
               └──────┬────────┘        │  (systemd)       │
                      │                 └────────┬─────────┘
         ┌────────────┼──────────┐               │
         │            │          │          GPIO17 edge
    tap button   swipe page  sys poll      (interrupt)
         │            │          │               │
         ▼            ▼          ▼               ▼
  ┌──────────────┐  SwipeView  /proc      read PID file
  │ hide window  │  handles    /sys       os.kill(SIGTERM)
  │ write PID    │  gesture                     │
  │ QProcess     │                              │
  │ fork/exec    │                              │
  │ child runs   │◄─────────── child exits ─────┘
  │ remove PID   │
  │ show window  │
  └──────────────┘

Challenges

Challenge 1: Recent Apps

Add a "last launched" indicator to each AppButton — show a small dot or timestamp on buttons that were recently used. Store the state in a QSettings file so it persists across reboots. Hint: QSettings with INI format writes to a plain text file.

Challenge 2: Live Thumbnails

Instead of Unicode icons, capture a screenshot of each app (using fbgrab or reading /dev/fb0) and display it as the button icon. Update the thumbnail each time the app exits.

Challenge 3: Third Page — Settings

Add a settings page with controls for display brightness (/sys/class/backlight/*/brightness), display rotation (QT_QPA_EGLFS_ROTATION), and a reboot button. Use QML Slider and Switch components.

Challenge 4: Animated Transitions

Add a fade-out animation before launching a child and fade-in when returning. Use QML PropertyAnimation on the root item's opacity property, with a SequentialAnimation that fades out, calls launcher.launch(), and fades in on return.


Deliverable

  • [ ] Launcher builds and runs on host (desktop window)
  • [ ] Launcher runs fullscreen on Pi via EGLFS
  • [ ] At least 2 child apps launch and return correctly
  • [ ] SwipeView navigation works with touch
  • [ ] System info page shows live CPU, memory, temperature
  • [ ] GPIO home button returns from child app to launcher
  • [ ] Both systemd services (launcher + home button) auto-start at boot