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:

StageDrawn by the shellInput
pickerdisk list (the target's own disk is hidden)Up/Down, A to confirm, B to leave
confirmhold-A gauge (Hold A to erase and install)hold A 3 s, B to cancel
progressstep number, step name, percentage barnone - the install must not be interrupted by a stray button
complete"Installation complete" + A: Reboot now, B: stayA reboots, B returns to the picker
errorthe 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

OwnsDoes NOT own
Persistent console UIProcess supervision
Controller-first navigationDRM/KMS or Wayland protocol
Game discovery and metadata presentationIPC protocol definitions
User-facing launch, resume, quit, crash flowsGame installation
Settings and status screensSave data management
Launch requests via restricted control IPCHardware 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 - see playos-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_scale for both the info block and the action rows (the info block used to advance at 16 * 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 * 45 so 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 sends StartInstaller with the chosen target_disk and init performs a console-free handoff to the runtime installer (compositor and /data are 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. See runtime-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 stateRendering behavior
ForegroundFull render at display refresh rate
Game launchingShow spinner; reduce to 10 FPS
Game is foregroundStop rendering — SetTargetFPS(0) or skip draw call
Game backgrounded (overlay visible)Shell stays stopped; overlay renders
Returning to foregroundResume 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

InputAction
D-pad Up/DownMove focus up/down in a list or grid
D-pad Left/RightMove focus left/right in a grid
A buttonConfirm / select focused item
B buttonBack / cancel
StartOpen settings screen
SelectToggle sort/filter (library screen)
L1 / R1Page left/right (future)
System buttonNever 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" notification
  • LaunchGameError(invalid_manifest) — show "This game cannot be launched" notification
  • Timeout (no GameStarted within 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:

ZoneContentSource
LeftBattery % + charging stateplayos_power_get_info()
CentreCPU/GPU temperatures, centred between the other zones (dropped when the bar is too narrow to hold all three without touching)playos_power_get_info()
RightPerformance profile, then the colour-coded thermal chipplayos_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).

ButtonIn gameShell UI
COMMAND (QUICK_MENU)screenshotscreenshot
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_v1 globals
  • Fullscreen xdg_toplevel surface — no decorations, no resize
  • wl_egl_window + EGL/GLES2 context (eglBindAPI(EGL_OPENGL_ES_API)), made current before raylib's rlgl init
  • Frame callbacks for v-sync pacing + eglSwapBuffers in SwapScreenBuffer()

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)