A direct-to-DRM phone OS frontend: status bar, lockscreen, app drawer, notifications, on-screen keyboard, settings panels (Wi-Fi, Bluetooth, flashlight, camera) — and the Wayland host that runs the apps themselves.
Single process. Takes DRM master directly via libdrm, renders the whole
UI in GLES via glproxy
(no Mesa, no mali_kbase access from this side), and hosts a Wayland
compositor for everything else on the device.
/dev/tty1 (local TTY auto-launches /root/.profile)
│
▼
/root/huskyfe/huskyfe ← single binary, holds DRM master
│
├── DRM/KMS direct ─────► panel + vsync
├── libinput touch ─────► gestures + on-screen kbd
├── EGL / GLES2 via glproxy ───► server-side Mali (chroot)
│
└── Wayland server (display=wayland-1)
▲
│ client surface dmabufs
│
┌────┴───────────┐
│ app processes │
│ (gtk, qt, etc.) │
└────────────────┘
The whole compositor is in one binary. It loads
libGLESv2.so.2 / libEGL.so.1 from
glproxy so every GL call
ends up at the proxy server inside the Halium chroot, which is where
libGLES_mali.so actually runs. There is no Mesa or
/dev/dri/renderD* involvement on this side — only /dev/dri/card0
for KMS modesetting + scanout dmabufs.
huskyfe/
├── Makefile # builds natively on the phone (g++/gcc)
├── deploy.sh # push src/Makefile/profile/.service → scp → make → run/restart
├── profile # /root/.profile — auto-launches huskyfe on local TTY login
├── huskyfe.service # systemd unit; notify-type with WatchdogSec=10s
├── huskyfe-portal-stub.service # tiny xdg-desktop-portal placeholder so GTK apps don't hang
├── xdg-desktop-portal.service # override that masks the real one (we provide the stub)
└── src/
├── main.cpp # the everything-file: DRM/KMS, GBM scanout, EGL/GLES setup,
│ libinput, frame loop, the entire UI state machine, gesture
│ recogniser, panel hierarchies. Big.
│
├── Renderer.{h,cpp} # GL state + draw helpers (textured quad, solid fill, scissor)
├── ImageRenderer.{h,cpp} # texture cache (stb_image / nanosvg sources) keyed by path
├── Text.{h,cpp} # FreeType glyph atlas + measure/draw routines
├── GL.{h,cpp} # one-time GL program/uniform initialisation
├── Blur.{h,cpp} # separable Gaussian for panel backgrounds
├── Spring.h # critically-damped spring (used by every transition)
│
├── Input.{h,cpp} # libinput wrapper: touch slots, key events, accel/proximity
├── Keyboard.{h,cpp} # on-screen keyboard: layouts, repeat, predictive, swipe
├── Haptics.{h,cpp} # /sys/class/leds/vibrator or input force-feedback shim
│
├── Status.{h,cpp} # top status bar (clock, battery, signal, wifi icon)
├── Background.{h,cpp} # wallpaper + lockscreen background
├── Icons.{h,cpp} # app icons: load from .desktop + image cache, swizzle, mask
├── Apps.{h,cpp} # .desktop scan, sort, launch (exec, env, cgroup), app drawer
├── Notifications.{h,cpp} # org.freedesktop.Notifications D-Bus service + UI surface
│
├── Wifi.{h,cpp} # NetworkManager D-Bus: scan, connect, password prompt
├── Bluetooth.{h,cpp} # BlueZ D-Bus: discover, pair, connect, A2DP/HFP
├── Camera.{h,cpp} # V4L2 capture preview into a GL texture
├── Flashlight.{h,cpp} # /sys/class/leds/flashlight torch toggle
│
├── WaylandHost.{h,cpp} # the embedded Wayland server: xdg-shell, viewporter,
│ text-input-v3, linux-dmabuf-v1. Manages client surfaces,
│ routes pointer/touch/keyboard, draws their dmabufs.
│
├── protocol/ # generated by wayland-scanner from /usr/share/wayland-protocols/
│ ├── xdg-shell-{server-protocol.h,protocol.c}
│ ├── linux-dmabuf-unstable-v1-{server-protocol.h,protocol.c}
│ ├── viewporter-{server-protocol.h,protocol.c}
│ └── text-input-unstable-v3-{server-protocol.h,protocol.c}
│
└── third_party/ # vendored single-header libraries
├── stb_image.h # PNG/JPG/GIF decoder
├── nanosvg.h # SVG parser
└── nanosvgrast.h # SVG rasteriser (used for app icons + UI vectors)
main.cpp runs a single thread that loops:
drmModePageFlipcallback fires (or a timer if we're not vsync-locked).Input::poll()drains libinput. Touch deltas turn into gesture-recognised events; key events go to the focused Wayland client or the on-screen keyboard.- The UI state machine advances all the springs and animations.
WaylandHost::dispatch()services Wayland clients — accepts new surfaces, processes pending requests, gathers the latest dmabuf each client wants drawn.- The GL pipeline draws everything in z-order onto the next scanout
buffer:
- Background (wallpaper or live camera, with blur if a panel is open)
- Hosted client surfaces (
WaylandHost::draw) - Status bar
- Panels (Wi-Fi, Bluetooth, notification shade) with parallax
- On-screen keyboard
- Toasts / haptic indicators
eglSwapBuffers— which here isglproxy's present, which writes into the scanout dmabuf shared with the kernel.drmModePageFlipfor the new buffer; the kernel scans it out at the next vsync.sd_notify("WATCHDOG=1")so systemd doesn't think we hung.
Every per-frame UI thing is a Spring — taps don't snap, panels rubber-band,
notifications slide in with momentum. src/Spring.h is a tiny
critically-damped-spring header — step(dt, target) once per frame, read
.value().
This is the second-biggest file and what makes huskyfe a compositor, not just a launcher.
- Listens on
$XDG_RUNTIME_DIR/wayland-1(set via env in the unit, not the defaultwayland-0, to avoid collisions with anything else on the system). - Globals advertised:
wl_compositor,wl_subcompositor,wl_shm,wl_seat,wl_output,xdg_wm_base,wp_viewporter,zwp_text_input_manager_v3,zwp_linux_dmabuf_v1. - Every client
wl_surfacegets aRunningAppentry with the resolvedapp_id(fromxdg_toplevel.set_app_id) and a title for the task switcher. - Touch is routed: a tap inside the hosted surface region forwards
through
wl_touch; a vertical edge swipe from the top opens notifications; a horizontal edge swipe from the bottom opens the task switcher. The compositor itself wins the gesture before forwarding. - Text input goes through
zwp_text_input_v3so the on-screen keyboard's character output reaches GTK/Qt clients without them having to know it's a virtual keyboard. - Client dmabufs are imported via
zwp_linux_dmabuf_v1→ EGLImage (server-side, insideglproxy-srv) and then sampled as a texture every frame. There is no zero-copy scanout of client dmabufs; we always composite through GL.
focus(handle) / unfocus() / close(handle) are the public API for
the task switcher and the long-press-to-close gesture.
There is no cross-compile. The Makefile is intended to run on the phone itself (or any aarch64 Linux with the same package set):
sudo apt install build-essential pkg-config \
libwayland-dev libdrm-dev libxkbcommon-dev libfreetype-dev \
libdbus-1-dev libinput-dev wayland-protocols
cd /root/huskyfe
make -j4 # produces /root/huskyfe/huskyfe (~5-8 MB)
make cleanThe Makefile compiles .cpp with g++ and .c (generated wayland
protocol code) with gcc, so the protocol bindings keep C linkage when
they're #included into the C++ TUs.
Headers it really wants:
| Dep | Pkg (Debian/Ubuntu) | Used for |
|---|---|---|
| libwayland-server | libwayland-dev |
hosted compositor |
| libdrm | libdrm-dev |
KMS modesetting + GBM scanout |
| libxkbcommon | libxkbcommon-dev |
keyboard layout for text-input |
| libfreetype | libfreetype-dev |
text rendering |
| libdbus-1 | libdbus-1-dev |
Notifications + NM + BlueZ |
| libinput | libinput-dev |
touch + key + accelerometer |
| wayland-protocols | wayland-protocols |
xml inputs for wayland-scanner |
| GLESv2 + EGL | provided by glproxy | every GL call |
-lGLESv2 -lEGL resolve to glproxy's libGLESv2.so.2 /
libEGL.so.1 via LD_LIBRARY_PATH=/usr/local/lib/glproxy (which the
service unit and /root/.profile set up).
The single script driving everything:
./deploy.sh # default: push + build + run
./deploy.sh push # scp src/ + Makefile + deploy.sh to phone
./deploy.sh build # push then make on phone
./deploy.sh run # stop other display owners + restart huskyfe
./deploy.sh clean # rm -rf build artifacts on phone
./deploy.sh logs # follow /tmp/huskyfe.log + journal -u glproxy-srv
./deploy.sh profile # install /root/.profile (autologin → huskyfe)
./deploy.sh service # install + enable huskyfe.serviceEnv knobs:
PHONE=root@192.168.1.148 # default — change for a different device
REMOTE=/root/huskyfe # destination path on the phonerun first kills every other display owner (pkill -9 -f 'wcomp|cage|phoc|phosh|weston|modetest|huskyfe') — only one process
can hold DRM master, and we want this one.
Two unit files in the repo, plus one drop-in profile:
huskyfe.service— the main unit.Type=notify+WatchdogSec=10s: huskyfe callssd_notify("WATCHDOG=1")every frame; if the render loop hangs for more than 10s, systemd kills and restarts, preventing the "frozen panel" failure mode.Conflicts=phosh.service phoc.service getty@tty1.serviceso it won't fight other display owners on enable.ExecStartPreensures the user session bus (/run/user/0/bus) exists and starts the chrootedglproxy-srvif it isn't already up.Restart=always+StartLimitBurst=10over 60s — recovers fromlibwayland'swl_resource_post_errorasserts (whichabort()) without leaving a black panel.
xdg-desktop-portal.service— overrides the system unit to a no-op (we don't want the real portal stack here; it pulls in GNOME-y things).huskyfe-portal-stub.service— provides a minimalorg.freedesktop.portal.Desktopso GTK apps that probe for it on startup get an immediate response instead of hanging on the bus.
Install with ./deploy.sh service (copies the unit, daemon-reload,
enables, starts).
Goes to /root/.profile. On a real local TTY (/dev/tty[0-9]*) login
it ensures glproxy-srv is running then exec /root/huskyfe/huskyfe
with stdout/stderr to /tmp/huskyfe.log. SSH/serial sessions are
exempt — tty returns /dev/pts/N for those, and the case match
skips them, so we still get a normal shell for debugging.
| Log | What |
|---|---|
/tmp/huskyfe.log |
huskyfe stdout/stderr — main UI log |
/tmp/glp.log |
glproxy-srv stderr (Mali side, inside chroot) |
journalctl -u huskyfe.service |
systemd transitions + watchdog kills |
journalctl -u glproxy-srv |
glproxy systemd lifecycle |
/tmp/huskyfe-dbus.log |
the per-session dbus-daemon if we started it |
./deploy.sh logs opens a paned tail of both huskyfe.log and the
glproxy journal.