playos-shell Specification
Repository:
playos-shell
Role: Persistent controller-first console UI; trusted Wayland client
Language: C (Raylib)
Cross-references: architecture.md §7.3, platform-api.md, sprints/Sprint-5.md, sprints/Sprint-6.md
Install screen stages (S14.5-T4)
The shell owns the install experience end to end. SCREEN_INSTALLER is the single
owner of the flow and has four stages:
| Stage | Drawn by the shell | Input |
|---|---|---|
| picker | disk list (the target's own disk is hidden) | Up/Down, A to confirm, B to leave |
| confirm | hold-A gauge (Hold A to erase and install) | hold A 3 s, B to cancel |
| progress | step number, step name, percentage bar | none - the install must not be interrupted by a stray button |
| complete | "Installation complete" + A: Reboot now, B: stay | A reboots, B returns to the picker |
| error | the reason init gave, + "A/B: Back" | A or B returns to the picker |
Confirming first sends PrepareInstall (see runtime-ipc.md), so an unusable
target fails on the picker instead of after the destructive work has begun, and
only then StartInstaller, which starts the screen-less worker. Progress arrives
as InstallProgress / InstallComplete / InstallError events relayed by init;
the shell never talks to the worker, and no second fullscreen app appears - the
old handoff (blanking the VT, stopping the shell) remains only for installs with
no shell to draw progress.
Responsibilities
| Owns | Does NOT own |
|---|---|
| Persistent console UI | Process supervision |
| Controller-first navigation | DRM/KMS or Wayland protocol |
| Game discovery and metadata presentation | IPC protocol definitions |
| User-facing launch, resume, quit, crash flows | Game installation |
| Settings and status screens | Save data management |
| Launch requests via restricted control IPC | Hardware driver details |
| Rendering with Raylib PlayOS backend | |
| Preserving UI state while a game runs |
Layout Conventions
- Every screen clears the frame first with
render_begin_frame(): the renderer keeps the previous frame's pixels, so a screen that forgets leaves the previous screen visible underneath it (seen on hardware with the installer screen over Settings - seeplayos-memorymap/08-gotchas.md). - Content column is
x = 0.15 * width, so labels, values, selection bars and the settings tab strip all share the same left/right edges. The tab strip splits that column into equal segments over a single track background. - Row rhythm is
8 * label_scalefor both the info block and the action rows (the info block used to advance at16 * label_scale, which read as a separate list). - Selectable rows highlight the full column width and carry a right-aligned affordance: a chevron for rows that open something (confirmation, disk picker, update check) or the relevant value (e.g. the pending version next to "Restart to Apply"). Dimmed text stays at 0.5 alpha minimum, unselected rows at 0.7.
- Hint lines sit at
height - scale * 45so they clear the status bar.
Screen Architecture
Library Screen (default)
│ A button → select game
▼
Game Detail Screen
│ A button → launch
│ B button → back
▼
Launching Screen (spinner)
│ game becomes foreground → shell backgrounds
│
[game running — shell alive but rendering stopped]
│
[PLAYOS_LIFECYCLE_FOREGROUND → shell returns]
▼
Library Screen (restored position)
│ (optionally shows post-crash notification)
Additional screens:
- Settings Screen — display brightness (live control via platform-api backlight), audio, system info, update check
- System Update Screen — current version, download/apply progress
- Installer Screen (S14-T10) — app-style front-end for installing PlayOS to an internal disk. Reached from Settings → System, it lists candidate disks read from
/sys/block(model + size), skipping virtual devices, removable media and the disk the system booted from, so the running system can never be chosen by accident. A continues to a destructive confirmation that requires holding A for 3 seconds (a tap can never erase a disk); B goes back. On confirm the shell sendsStartInstallerwith the chosentarget_diskand init performs a console-free handoff to the runtime installer (compositor and/dataare kept, only the target's ESP is released, so the install reads as a screen change rather than a restart), which installs that disk directly instead of asking again. Seeruntime-ipc.md. - Recovery Screen (S14-T6) — reboot/shutdown/factory reset/rollback/logs, shown when the shell is launched with
PLAYOS_RECOVERY=1
Rendering Lifecycle
| Shell state | Rendering behavior |
|---|---|
| Foreground | Full render at display refresh rate |
| Game launching | Show spinner; reduce to 10 FPS |
| Game is foreground | Stop rendering — SetTargetFPS(0) or skip draw call |
| Game backgrounded (overlay visible) | Shell stays stopped; overlay renders |
| Returning to foreground | Resume rendering; restore previous screen and cursor position |
The shell surface remains alive (mapped as a Wayland surface) at all times. Only rendering is stopped, not the process.
A suspended shell ignores gamepad input. While a game is foreground (is_suspended) the shell must not act on UI input at all: the main loop skips the entire screen-update dispatch, so no screen handler can react to a button the game is using. This is not cosmetic — the game-detail screen's B ("back to library") calls playos_trusted_terminate_game(), and with the update handlers running while suspended, pressing B during gameplay killed the running game and looked like the game quitting on B. Two deliberate exceptions stay live while suspended: the reserved-button gestures (COMMAND tap → pause overlay, ARMOURY CRATE tap → screenshot) and the hardware volume keys.
Controller Navigation Rules
| Input | Action |
|---|---|
| D-pad Up/Down | Move focus up/down in a list or grid |
| D-pad Left/Right | Move focus left/right in a grid |
| A button | Confirm / select focused item |
| B button | Back / cancel |
| Start | Open settings screen |
| Select | Toggle sort/filter (library screen) |
| L1 / R1 | Page left/right (future) |
| System button | Never reaches shell — intercepted by compositor |
Mouse and keyboard are not required for normal shell use. They may be used for development convenience.
Game Discovery
The shell scans playos_storage_get_games_root() on startup and on explicit refresh:
// Pseudo-code
const char *games_root = playos_storage_get_games_root();
DIR *dir = opendir(games_root);
while ((entry = readdir(dir))) {
char manifest_path[PATH_MAX];
snprintf(manifest_path, sizeof(manifest_path),
"%s/%s/manifest.json", games_root, entry->d_name);
GameManifest manifest;
if (parse_manifest(manifest_path, &manifest) == 0) {
game_list_add(&games, &manifest);
} else {
PLAYOS_LOG_W("shell", "Skipping invalid manifest: %s", manifest_path);
}
}
sort_games_by_name(&games);
Game icons are loaded as Raylib Texture2D from <install_path>/assets/icon.png. A placeholder texture is used when no icon is present.
Launch Flow (Shell Side)
User selects "Launch" on game detail screen
│
├── Shell sends LaunchGame via control IPC (playos-runtime client)
│
├── Shell transitions to "Launching" screen (spinner + game name)
│
├── Receives GameStarted event (async) — game PID is known
│
└── Receives PLAYOS_LIFECYCLE_BACKGROUND
│
└── Shell stops rendering; game is now foreground
Error handling:
LaunchGameError(already_running)— show "A game is already running" notificationLaunchGameError(invalid_manifest)— show "This game cannot be launched" notification- Timeout (no
GameStartedwithin 10s) — show "Launch failed, please try again"
Post-Game Return Flow
When the game exits or crashes, the compositor delivers PLAYOS_LIFECYCLE_FOREGROUND to the shell:
Shell receives PLAYOS_LIFECYCLE_FOREGROUND
│
├── Resume rendering
├── Restore previous library position and selection
│
└── If crashed == true:
Show notification overlay: "Game exited unexpectedly"
Options: "Restart" | "Back to Library"
Status Bar
Persistent footer visible on all shell screens, laid out in three zones so the bar reads evenly instead of clustering everything on the left:
| Zone | Content | Source |
|---|---|---|
| Left | Battery % + charging state | playos_power_get_info() |
| Centre | CPU/GPU temperatures, centred between the other zones (dropped when the bar is too narrow to hold all three without touching) | playos_power_get_info() |
| Right | Performance profile, then the colour-coded thermal chip | playos_power_get_info().active_profile / .thermal_state |
Updated every 30 seconds for battery/thermal; every 1 second for clock.
Screenshots
A screenshot is a PNG of the composited output (game, pause overlay and shell together), written to /data/screenshots/playos-<epoch>.<ms>.png on the persistent data partition.
Capture path. The shell cannot see game pixels through its own framebuffer, so it captures the output with the wlroots zwlr_screencopy_manager_v1 protocol (src/screencopy.c): it binds wl_shm, wl_output and the screencopy manager on raylib's existing Wayland connection (GetWindowHandle()), copies the output into a shared-memory buffer, converts it to Raylib's RGBA, and writes the PNG with ExportImage(). The manager is bound at protocol version 1 and every member of the generated frame listener is filled — libwayland aborts the process on a NULL listener slot, which once killed the shell (see 08-gotchas.md). The pixel conversion honours the wl_shm format announced in the buffer event: the buffer is little-endian, so XRGB8888/ARGB8888 arrive as B,G,R,X and XBGR8888/ABGR8888 as R,G,B,X (wlroots offers the latter, so assuming B,G,R,X unconditionally swapped red and blue). If the manager is unavailable (nested/headless testing, compositor without screencopy) the shell falls back to LoadImageFromScreen(), which only covers its own surface and therefore only works while the shell is the foreground client.
Gesture (reserved buttons).
| Button | In game | Shell UI |
|---|---|---|
COMMAND (QUICK_MENU) | screenshot | screenshot |
ARMOURY CRATE (SYSTEM) | open the pause overlay | — (no game to overlay) |
Two distinct buttons rather than tap-vs-hold on one, because the hardware cannot express a hold: on the ROG Ally (hid-asus) the Command Center button emits KEY_F16 press+release within the same poll and Armoury Crate emits KEY_PROG1 the same way — a momentary pulse with no sustained down state. (The volume keys on the vendor node do report sustained presses — 288–321 ms measured — so the shell can see holds when a device produces them; these two buttons simply don't.) All gestures are therefore edge triggered via shell_input_button_pressed(), which explicitly catches a press+release that lands inside one poll. A 0.4 s debounce suppresses repeat captures from a bouncing button, and each trigger logs screenshot requested (COMMAND) / ARMOURY CRATE tap - showing overlay so the path is diagnosable from the log alone.
Preference. The System tab's "Screenshot on COMMAND" toggle persists to /data/config/screenshot (1/0) and defaults to on; the file is written on every toggle and read at startup. It gates COMMAND only.
Feedback. On shell screens a centred "Screenshot saved/failed" toast is shown for 1.5 s and the shell logs screenshot -> <path> (ok=N). While a game or the overlay is on screen the shell surface is not visible, so the capture is silent on screen — check /data/log/shell-stderr.log or the file itself.
Trusted Client Identity
The shell sets PLAYOS_TRUSTED_SHELL=1 in its own environment before connecting to the Wayland display. The compositor verifies this at connection time and assigns the playos_shell_v1 role.
The shell uses the playos-runtime restricted control client library to:
- Connect to
/run/playos/control.sock - Send
LaunchGame,QueryStatus,Shutdown,Reboot,FactoryReset - Receive async events:
GameStarted,GameExited,ThermalStateChanged
Raylib PlayOS Backend (rcore_playos.c)
Raylib 6.0 is active (Sprint 5.5). It is vendored into
playos-shell/external/raylib (pinned by RAYLIB_COMMIT in
versions.lock) and built as a static library with the custom
PLATFORM_PLAYOS backend (external/raylib/src/platforms/rcore_playos.c).
The backend implements:
- Wayland connection and
wl_compositor/xdg_wm_base/playos_manager_v1globals - Fullscreen
xdg_toplevelsurface — no decorations, no resize wl_egl_window+ EGL/GLES2 context (eglBindAPI(EGL_OPENGL_ES_API)), made current before raylib'srlglinit- Frame callbacks for v-sync pacing +
eglSwapBuffersinSwapScreenBuffer()
Rendering is Raylib-only; Raylib is not the input path. Controller input
stays shell-owned direct evdev (src/input.c) so reserved SYSTEM/QUICK_MENU
buttons survive. PollInputEvents() in the backend only resets raylib's
internal input state. Lifecycle events are polled in main.c via
playos_lifecycle_poll() — suspend/background skips BeginDrawing/
EndDrawing, and TERMINATE exits cleanly (running = false, no bare
exit()).
Build
# playos-shell/CMakeLists.txt (PLAYOS_SHELL_USE_RAYLIB=ON)
add_subdirectory(external/raylib) # vendored Raylib 6.0, PLATFORM=PlayOS
find_library(PLAYOS_LIB playos ...) # libplayos from playos-platform-api
target_sources(playos-shell PRIVATE
src/main.c
src/input.c
src/screen_home.c
src/screen_library.c
src/screen_game_detail.c
src/screen_settings.c
src/render_util.c
)
target_link_libraries(playos-shell PRIVATE raylib ${PLAYOS_LIB} m)