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
libgpiodto 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:
- Displaying a home screen with app buttons (like a car infotainment system)
- Hiding its window when launching a child — releasing DRM master so the child can render
- 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:
You should see:
Checkpoint
All 4 files present in qt_launcher/.
2. Understand the Architecture
2.1 CMakeLists.txt
Open the build file:
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:
No other display app can be running
EGLFS requires exclusive DRM master. Stop any running display services first:
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:
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:
Read the current value (should be 1 with button released):
Monitor edges — press the button and see events:
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:
- Writer (launcher) creates
/tmp/launcher_child.pidcontaining the child's PID - Reader (home button daemon) reads the PID when the button is pressed
- 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:
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):
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:
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:
- EGLFS rendering — direct GPU, no compositor overhead
- SwipeView — gesture-based page switching with zero custom touch code
- DRM master handoff — hides window to release GPU, reclaims on child exit
- procfs/sysfs — the same
/procand/sysfiles used throughout the course, now from C++ - GPIO home button —
libgpiodedge detection, interrupt-driven (zero CPU while waiting) - PID file + signal IPC — launcher writes PID, daemon reads it and sends SIGTERM
- 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