Skip to content

Quick Start: Building and Deploying on the Spaceboard

Portal is a Wayland compositor stack for AR glasses spatial computing, running on the Spaceboard — an HP EliteBook Ultra G1q powered by the Snapdragon X Elite (X1E80100). This guide walks beginner developers through the end-to-end process: verifying prerequisites, building every component, deploying to the device, and confirming that all 15+ systemd services are healthy. The primary build strategy is native compilation directly on the Spaceboard (not cross-compilation from a Mac), because the device has Rust 1.97.0, gcc 15.3, and all Portal build dependencies installed natively.

Sources: README.md#L1-L150

Prerequisites: What You Need Before You Start

Section titled “Prerequisites: What You Need Before You Start”

Before touching any build commands, confirm that your Spaceboard meets the hardware and software requirements. The device runs a custom kernel (7.0.13-iris) on Debian testing (trixie), with Qualcomm’s IRIS hardware video encoder and Adreno X1-85 GPU exposed through Mesa 26.1.2.

Component Requirement How to Verify
Machine HP EliteBook Ultra G1q (X1E80100) ssh hp "uname -m"aarch64
OS Debian testing (trixie) ssh hp "cat /etc/debian_version"
Kernel 7.0.13-iris (custom, IRIS + videocc-sm8550) ssh hp "uname -r"
Rust 1.97.0 (pinned via rust-toolchain.toml) ssh hp "rustc --version"
Cargo Cargo 1.97+ ssh hp "cargo --version"
C Compiler gcc 15.3 ssh hp "gcc --version"
QNN SDK QAIRT v2.44 at /opt/qairt ssh hp "ls /opt/qairt"
SSH Access ssh hp alias configured ssh hp "echo ok"

The Rust toolchain is pinned to exactly 1.97.0 in the repository’s rust-toolchain.toml, ensuring that every developer and the CI pipeline compile with identical semantics.

Sources: rust-toolchain.toml#L1-L4, docs/x-elite-deployment-state.md#L9-L24

Clone the repository and SSH into the Spaceboard to begin:

Terminal window
# On your development machine
git clone <repo-url> portal-development
cd portal-development
# Verify SSH connectivity to the Spaceboard
ssh hp "rustc --version && cargo --version"
# Expected: rustc 1.97.0 / cargo 1.97.0

The workspace uses Cargo with a resolver v2 layout containing 30+ crates spanning spatial rendering, streaming, input, voice, NPU, and application framework subsystems.

Sources: Cargo.toml#L1-L45


Portal produces artifacts across three distinct build systems, each serving a different layer of the stack. Understanding which build to run — and when — is the first architectural decision a new developer faces.

flowchart TD
    subgraph "Rust Workspace (cargo)"
        RW["cargo build --workspace --release<br/>30+ crates → binaries + cdylibs"]
    end
    subgraph "C++ Plugins (cmake)"
        CP["cmake -B build && cmake --build build<br/>portal-spatial + portal-spatial-warp shims"]
    end
    subgraph "C Streaming (gcc + Makefile)"
        CS["./build.sh<br/>portal_stream binary (GStreamer pipeline)"]
    end
    
    RW -->|libportal_spatial.so, portal-wm, portal-voiced...| INSTALL["/usr/local/lib/ + /usr/local/bin/"]
    CP -->|portal-spatial.so, portal-spatial-warp.so| WAYFIRE["Wayfire plugin directory"]
    CS -->|portal_stream| STREAM["portal-stream.service"]
    
    WAYFIRE --> COMPOSITOR["portal.service (Wayfire)"]
    STREAM --> COMPOSITOR
    INSTALL --> SERVICES["All portal-*.service units"]

The three build paths are independent but produce interdependent artifacts: the C++ shims load Rust cdylibs at runtime via dlopen, and the C streaming binary captures Wayland surfaces produced by the Rust-driven compositor.

Sources: justfile#L1-L67, README.md#L34-L54


The Rust workspace is the largest build — it compiles 30+ crates including the spatial domain model, Wayfire plugins (as Rust cdylibs), the window manager daemon, input handling, voice pipeline, NPU runtime, and the PCP application framework.

Section titled “Individual Crates (Recommended for Iteration)”

When iterating on a specific component, build only the crate you changed to minimize compile time:

Terminal window
# On the Spaceboard (ssh hp)
cargo build -p portal-spatial-plugin --release # Wayfire spatial plugin (Rust cdylib)
cargo build -p portal-warp --release # Spatial warp rendering (Rust cdylib)
cargo build -p portal-wm --release # Window manager daemon
cargo build -p portal-input --release # Input handling
cargo build -p portal-voice --release # Voice daemon (requires QNN features)
cargo build -p portal-llm --release # LLM daemon (requires hexagon feature)

For a clean deploy or first-time setup, build everything at once:

Terminal window
cargo build --workspace --release

Voice and LLM Daemon — Special Feature Flags

Section titled “Voice and LLM Daemon — Special Feature Flags”

The voice and LLM daemons require NPU-specific feature flags to enable QNN (Hexagon) and Sherpa-onnx backends. Without these flags, the STT engine is compiled out entirely:

Terminal window
# Voice daemon: VAD + TTS + STT + network audio
cargo build --release \
--features "qnn-tts,qnn-vad,npu-stt,network-audio" \
--bin portal-voiced
# LLM daemon: Hexagon DSP support for GenieX llama.cpp
cargo build --release \
-p portal-llm --features hexagon \
--bin portal-llmd
Crate Output Binary Feature Flags Required Install Path
portal-spatial libportal_spatial.so /usr/local/lib/
portal-spatial-plugin libportal_spatial_plugin.so /usr/local/lib/
portal-warp libportal_spatial_warp.so /usr/local/lib/
portal-wm portal-wm /usr/local/bin/
portal-input portal-keyboardd /usr/local/bin/
portal-voice portal-voiced qnn-tts,qnn-vad,npu-stt,network-audio /usr/local/bin/
portal-llm portal-llmd hexagon /usr/local/bin/
portal-launcher portal-launcherd /usr/local/bin/
portal-pcp/daemon portal-pcpd /usr/local/bin/

Sources: README.md#L43-L54, docs/x-elite-deployment-state.md#L168-L175, Cargo.toml#L1-L45


Step 2: Build the C++ Wayfire Plugin Shims

Section titled “Step 2: Build the C++ Wayfire Plugin Shims”

The Wayfire plugins are thin C++ shims that bridge between Wayfire’s C++ plugin API and the Rust cdylib implementations. They use CMake and require the Wayfire development package to be installed.

Terminal window
# On the Spaceboard (ssh hp)
cd portal/wayfire-plugins
cmake -B build
cmake --build build
# Result: build/portal-spatial.so and build/portal-spatial-warp.so
# These are Wayfire plugins that dlopen the Rust cdylibs at runtime

The CMakeLists.txt discovers the Wayfire plugin directory via pkg-config and builds two plugin targets. The keyboard plugin that previously lived here is deprecated — keyboard rendering is now a standalone DRM/KMS daemon.

Sources: portal/wayfire-plugins/CMakeLists.txt#L1-L34, justfile#L22-L28


The streaming pipeline is written in C and uses GStreamer to capture Wayland DMA-BUF surfaces, encode them to H.265 via the Qualcomm IRIS hardware encoder, and packetize the output as RTP/UDP. This binary is separate from the Rust workspace.

Terminal window
# On the Spaceboard (ssh hp)
cd portal/streaming
./build.sh
# Result: portal_stream binary in portal/streaming/

The build script handles Wayland protocol binding generation (using wayland-scanner against pinned wlr-protocols v0.2.1), applies compiler hardening flags (stack canaries, FORTIFY_SOURCE, full RELRO, PIE), and links against GStreamer, libdrm, GBM, EGL, GLESv2, and OpenSSL. There is also a ./build.sh test subcommand that runs cmocka unit tests for HMAC authentication utilities.

Sources: portal/streaming/build.sh#L1-L91, portal/scripts/start-stream.sh#L1-L35


Portal offers two deployment paths depending on whether you are setting up a fresh install or iterating on changes.

Option A: Fresh Install — One-Shot Deployment Script

Section titled “Option A: Fresh Install — One-Shot Deployment Script”

For a clean Spaceboard (e.g., after a reflash), use the deployment script which installs all configurations, systemd units, and system software in a single pass:

Terminal window
# Clone the repo on the Spaceboard first
git clone <repo-url> ~/portal-development
# Run as root (sudo)
sudo ./portal/scripts/deploy-spaceboard.sh

The script performs 12 ordered steps:

Step Action Details
1/12 Create directories /run/portal, /var/lib/portal, user home dirs
2/12 NetworkManager config dns=none to prevent dnsmasq port 53 conflict
3/12 Mask conflicting services dnsmasq.service, getty@tty1.service
4/12 Set default target multi-user.target (no graphical login manager)
5/12 Portal config Wayfire config to /etc/portal/wayfire.ini
6/12 Network configs hostapd (5 GHz AP) + dnsmasq DHCP pool
7/12 Systemd services 13 portal service units to /etc/systemd/system/
8/12 Helper scripts File watcher, Wine monitor, health monitor, Wi-Fi watchdog
9/12 Patched hostapd ath11k EALREADY/EBUSY tolerance (build from source)
10/12 System software box64, LibreOffice, inotify-tools, Wine 10.0
11/12 Log files /var/log/portal-health.log and monitor logs
12/12 Enable services systemctl enable all 13 services + daemon-reload

After the script completes, reboot to verify a clean boot sequence.

Sources: portal/scripts/deploy-spaceboard.sh#L1-L100

Option B: Iterative Deploy — Copy Individual Binaries

Section titled “Option B: Iterative Deploy — Copy Individual Binaries”

When you rebuild a single component and need to push it live, use scp and systemctl restart:

Terminal window
# --- Rust binaries ---
scp ./target/release/portal-wm hp:~/
ssh hp "sudo cp ~/portal-wm /usr/local/bin/ && sudo systemctl restart portal-wm"
# --- Rust cdylibs (spatial plugins) ---
scp ./target/release/libportal_spatial.so hp:~/
ssh hp "sudo cp ~/libportal_spatial.so /usr/local/lib/ && sudo ldconfig"
ssh hp "sudo systemctl restart portal"
# --- C++ Wayfire plugins ---
scp portal/wayfire-plugins/build/portal-spatial.so hp:~/
ssh hp "sudo cp ~/portal-spatial.so \$(pkg-config --variable=plugindir wayfire)/"
ssh hp "sudo systemctl restart portal"
# --- C streaming binary ---
scp portal/streaming/portal_stream hp:~/
ssh hp "sudo cp ~/portal_stream /usr/local/bin/ && sudo systemctl restart portal-stream"

Important: After copying any .so file, run sudo ldconfig on the Spaceboard to update the dynamic linker cache before restarting dependent services.

Sources: README.md#L73-L83, docs/x-elite-deployment-state.md#L137-L151


Service Topology: Understanding Startup Order

Section titled “Service Topology: Understanding Startup Order”

Portal manages 15+ systemd services that start in a strict dependency chain. The compositor (portal.service) is the root — every other service waits for the Wayland socket to appear before starting.

flowchart TD
    BOOT["multi-user.target"] --> PORTAL["portal.service<br/>(Wayfire DRM backend)"]
    BOOT --> HEALTH["portal-health-monitor"]
    BOOT --> WATCHDOG["wlo1-watchdog"]
    BOOT --> BINFMT["portal-binfmt-fix"]
    BOOT --> IRIS["portal-iris-fixup"]
    
    PORTAL -->|"socket: /run/portal/wayland-1"| INPUT["portal-input<br/>(keyboard + touch)"]
    PORTAL -->|"socket"| STREAM["portal-stream<br/>(IRIS H.265 → RTP)"]
    PORTAL --> WM["portal-wm<br/>(window manager)"]
    PORTAL --> LAUNCHER["portal-launcher<br/>(app daemon)"]
    PORTAL --> LLM["portal-llm<br/>(on-device LLM)"]
    
    DBUS["portal-dbus<br/>(session bus)"] --> PCP["portal-pcp<br/>(capability daemon)"]
    PORTAL --> DBUS
    PCP --> INPUT
    
    LLM --> VOICE["portal-voice<br/>(VAD + STT + TTS)"]
    
    PORTAL --> NET["portal-network<br/>(hostapd 5GHz AP)"]
    
    style PORTAL fill:#e1f5fe,stroke:#0288d1,stroke-width:2px
    style PCP fill:#fff3e0,stroke:#f57c00
    style VOICE fill:#f3e5f5,stroke:#7b1fa2

Each service includes an ExecStartPre bash one-liner that polls for the Wayland socket at /run/portal/wayland-1 with a 30-attempt retry loop (15-second timeout). This ensures robust startup even if Wayfire’s initialization takes a few seconds longer than expected.

Sources: portal/systemd/portal.service#L1-L29, portal/systemd/portal-input.service#L1-L45, portal/systemd/portal-stream.service#L1-L34, docs/x-elite-deployment-state.md#L29-L50

Services read their runtime parameters from environment files in /etc/portal/. The repository ships .example templates that you must copy and customize:

Environment File Copied To Key Variables Purpose
portal-stream.env.example /etc/portal/portal-stream.env PORTAL_STREAM_TARGET, STREAM_PORT RTP destination IP + port for H.265 stream
portal-voice.env.example /etc/portal/portal-voice.env PORTAL_GLASSES_IP, PORTAL_VENV_DIR TTS audio destination + GenieX Python venv
portal-network.env.example /etc/portal/portal-network.env PORTAL_NETWORK_CIDR, PORTAL_DHCP_RANGE Wi-Fi AP subnet + DHCP pool for glasses
portal-pcp.env /etc/default/portal-pcp PORTAL_UID PCP daemon user override

Critical hardening note: Since v0.2.1, PORTAL_STREAM_TARGET and PORTAL_VENV_DIR have no hardcoded fallbacks. The binaries exit with a clear error message if these are missing, preventing deployment-specific IPs or paths from being baked into source code.

Sources: portal/systemd/portal-stream.env.example#L1-L22, portal/systemd/portal-voice.env.example#L1-L23, portal/network/portal-network.env.example#L1-L22


After deploying and rebooting (or restarting services), verify that all components are operational.

Terminal window
ssh hp "systemctl status portal portal-input portal-stream portal-network \
portal-wm portal-pcp portal-dbus portal-launcher portal-voice portal-llm \
portal-health-monitor wlo1-watchdog portal-binfmt-fix"
Terminal window
# Wayfire should own both eDP-1 and GLASSES-1 outputs
ssh hp "WAYLAND_DISPLAY=wayland-1 XDG_RUNTIME_DIR=/run/portal wlr-randr"
# Expected: eDP-1 (2240×1400) + GLASSES-1 (2560×1080)
# Confirm streaming is active (60 fps, zero drops)
ssh hp "journalctl -u portal-stream --no-pager -n 20 | grep -i fps"
Terminal window
# Check hostapd is running and glasses can connect
ssh hp "systemctl status portal-network"
# The AP broadcasts as SSID "Spaceboard" on 5 GHz channel 36
Terminal window
# Check that DSP contexts initialized
ssh hp "journalctl -u portal-voice --no-pager -n 30 | grep -i 'qnn\|vad\|tts'"
ssh hp "journalctl -u portal-llm --no-pager -n 20 | grep -i 'geniex\|qwen'"
Check Command Expected Result
All services active ssh hp "systemctl is-active portal*" All return active
Wayland socket exists ssh hp "ls /run/portal/wayland-1" Socket file present
PCP IPC socket ssh hp "ls /run/portal/pcp.sock" Socket file present
LLM IPC socket ssh hp "ls /run/portal/llm.sock" Socket file present
Wi-Fi AP up ssh hp "ip addr show uap0" 192.168.50.1/24 assigned
Streaming at 60fps journalctl -u portal-stream 60.0 fps with zero drops
No crash loops journalctl -u portal-health-monitor No “3+ restarts” warnings

Sources: docs/x-elite-deployment-state.md#L29-L50, docs/x-elite-deployment-state.md#L247-L248


The recommended day-to-day loop is to write code on your local machine, then build and deploy on the Spaceboard via SSH. The justfile provides shorthand commands for common build operations.

flowchart LR
    CODE["Edit code<br/>(local machine)"] --> SYNC["Sync to Spaceboard<br/>(git push/pull or rsync)"]
    SYNC --> BUILD["Build on Spaceboard<br/>ssh hp 'cargo build -p <crate> --release'"]
    BUILD --> DEPLOY["Deploy binary<br/>scp + systemctl restart"]
    DEPLOY --> TEST["Verify<br/>journalctl + wlr-randr"]
    TEST -->|iterate| CODE

The justfile wraps common build commands with the just task runner:

Terminal window
# On the Spaceboard
just build-rust # Full workspace (ARM64 target)
just build-crate portal-wm # Single crate
just build-cpp # C++ Wayfire plugins
just build-all # Everything
just test # Run workspace tests
just clean # Remove all build artifacts

Sources: justfile#L1-L67, README.md#L129-L133


Local Smoke Testing with the ARM64 Sandbox

Section titled “Local Smoke Testing with the ARM64 Sandbox”

When you need to quickly test an ARM64 binary on your x86_64 development machine (macOS or Linux), use the QEMU-based Docker sandbox. This is useful for catching crashes or logic errors before deploying to the Spaceboard, though it cannot exercise hardware-specific paths (GPU, NPU, DRM).

Terminal window
# Cross-compile for ARM64 (from your dev machine)
cargo build --target aarch64-unknown-linux-gnu --release
# Smoke-test in the ARM64 sandbox
./run_sandbox.sh ./target/aarch64-unknown-linux-gnu/release/portal-wm --help

The sandbox runs arm64v8/debian:bookworm with QEMU user-mode emulation, matching the glibc and libstdc++ versions on the Spaceboard. It is explicitly a smoke test environment — not a substitute for building and running on the actual hardware.

Sources: run_sandbox.sh#L1-L29, docs/ARM64_Sandbox_Environment_Setup.md#L28-L55

The repository includes Cross.toml and CI workflows for cross-compilation via cross-rs, but the README explicitly warns against cross-compiling from a Mac for production builds. The Spaceboard’s native toolchain has all the system libraries (Wayland, GStreamer, QNN) that cross-compilation would need to replicate, making native builds simpler and more reliable.

Build Method Tool Use Case
Native on Spaceboard cargo build --release Production builds, daily development
Cross-compile via cross cross build --target aarch64-unknown-linux-gnu CI pipeline only
Sandbox smoke test run_sandbox.sh Quick binary validation on x86_64 dev machine

Sources: Cross.toml#L1-L13, .github/workflows/cross-compile.yml#L1-L28, README.md#L36-L37


Troubleshooting Common Build and Deploy Issues

Section titled “Troubleshooting Common Build and Deploy Issues”
Symptom Root Cause Fix
cargo build fails with missing system libs Wayland/GStreamer dev packages not installed sudo apt install libwayland-dev libgstreamer1.0-dev ...
Voice daemon silently does nothing Missing npu-stt feature flag Rebuild with --features "qnn-tts,qnn-vad,npu-stt,network-audio"
portal-stream exits immediately PORTAL_STREAM_TARGET unset in env Copy portal-stream.env.example to /etc/portal/portal-stream.env
Wayfire starts but no windows appear GLASSES-1 output positioned wrong Check /etc/portal/wayfire.ini has [output:GLASSES-1] position = 0,0
Wine apps fail with QEMU errors qemu-x86_64 shadowing box64 in binfmt_misc portal-binfmt-fix.service should handle this; verify it’s active
Services restart in a loop systemd-logind deletes /run/user/1000 socket All services use /run/portal as XDG_RUNTIME_DIR — verify portal.service ExecStartPre creates it
Wi-Fi AP drops under load ath11k concurrent STA+AP driver bug wlo1-watchdog.service auto-reconnects every 10s; check it’s enabled

Sources: docs/x-elite-deployment-state.md#L222-L284, portal/compositor/wayfire.ini#L1-L48, portal/systemd/portal-stream.service#L13-L18


Now that you have Portal building and running on the Spaceboard, explore the deeper architecture and development workflows: