ARCH R

Create modules (preview)

Draft specification for archr-launcher modules - execution model, module.toml, permissions, extension points, JSON-RPC protocol and two examples.

In development. Modules are not implemented yet. This page summarizes the current design so developers can follow it; names, fields and limits may still change before the first release. Nothing here works on a device today.

A module is an external program, in any language the image can run, that archr-launcher starts inside a sandbox and talks to over JSON. The module declares what it adds (Start menu tiles, quick menu items, Settings pages, event hooks) in a module.toml; the launcher draws everything with its own components. A module never draws pixels, never reads the controller directly and never runs as root.


Execution model

Each module is a separate process speaking JSON-RPC 2.0, one object per line (NDJSON), over its stdin and stdout. The launcher starts it with systemd-run --pipe as the unprivileged archr-mod user, with sandbox and cgroup limits set by systemd.

Two modes, declared in the manifest:

modeprocess lifetimetypical usecost
oneshotone process per message: one line in, zero or more lines out, then it exitshooks and rare actionsnothing resident in RAM; each call pays the process start
daemonone process while the launcher is openlive values (battery), filters, screensone resident process plus the systemd-run that waits for it

Measured on an R36S, starting a sandboxed systemd-run --pipe takes about 190 ms and a simple jq call about 26 ms. A oneshot module is fine for user actions (opening a page, pressing an item); frequent events such as changing the selected game should go to a daemon.

Lifetime rules:

  • No module runs during a game. Before handing the screen to a game, the launcher stops every module (500 ms, then the cgroup is killed). When the launcher comes back, the module receives the game.ended event.
  • Daemons start after the first frame and after the library has loaded, one at a time, with Nice=10. They never delay the boot.
  • End of stdin (EOF) means the module must exit. The launcher sends shutdown first.
  • At most 3 daemons at the same time; any other module has to be oneshot.

Languages

The guaranteed runtimes are shell (sh/bash) with jq and native aarch64 binaries (Rust, Go, C). Python may be present on the image, but a module must not depend on it.


Package layout

<id>/
  module.toml          required
  main.sh | bin/<aarch64 binary>
  locales/en.toml      required; pt_BR.toml, es.toml, ru.toml recommended
  icons/<name>.svg     unmodified Lucide SVGs, only the ones used
  preview.png          640×480, required for the Module Store
  LICENSE
  README.md

Installed modules go to /storage/.config/archr-launcher/modules/<id>/. Each module also gets a writable data folder, /storage/.config/archr-launcher/module-data/<id>/, and a read-only copy of its settings, /storage/.config/archr-launcher/module-settings/<id>.json, which only the launcher writes.


module.toml

id = "bateria-detalhada"        # [a-z0-9-]{3,40}, unique on the device
version = "1.0.0"               # semver
api_version = 1                 # protocol and manifest version
min_launcher = "1.0.0"
name = "@name"                  # "@" = key from the module's locales/<language>.toml
description = "@description"
author = "Fulano <fulano@exemplo.org>"
license = "MIT"
homepage = "https://github.com/fulano/archr-bateria"
icon = "battery-full"           # Lucide name; the SVG ships in icons/

[run]
runtime = "bash"                # "sh" | "bash" | "native"
entry = "main.sh"
mode = "daemon"                 # "daemon" | "oneshot"

[limits]
memory_mb = 24                  # default 32, maximum 64

[permissions]                   # all optional; missing = denied
# net = false
# read = ["roms"]
# hooks = ["launch"]

[[hooks]]
event = "focus.game"            # event subscription

# extension points: [[start.tiles]], [[start.shortcuts]], [[quick]],
# [[settings.pages]] + [[settings.pages.rows]], [[screens]], [[collections]],
# [[replace]], [provider.metadata]

Unknown keys and wrong types produce a warning and are ignored, as in themes. Only id, version, api_version, run and an unknown permission are fatal, so the user always knows exactly what they grant.

Translations: locales/<language>.toml uses the same flat key = "text" format as the launcher's own catalogs (en, pt_BR, es, ru). Labels written as @key are looked up in the module's catalog, falling back to en. Dynamic text can arrive ready (text) or as {"key": "@status.charging", "args": {...}} for the launcher to format.

Icons: Lucide only. icon = "<name>" plus the unmodified SVG in icons/<name>.svg; the validator compares it with the Lucide 0.468.0 set and rejects anything else.


Extension points

Everything is declared in the manifest, so the launcher can show it without starting the module; only dynamic content (values, lists, decisions) comes from the process. The design is split into phases.

Phase 1 (first version):

  • Start menu tiles ([[start.tiles]]): a tile in the top row, up to 2 per module and 4 in total, with an optional live value updated by ui.update. Shortcuts ([[start.shortcuts]]): one per module, two in total, in the bottom row before Sleep. Selecting either one opens a module page (open = "<page>") or sends an action (action = "<name>").
  • Quick menu (Y) item ([[quick]]): context = "game" or "system", an optional systems = [...] filter and an action, inserted before Sleep. The action receives {system, game_key, rom} for a game or {system} for a system. One item per module, three per menu.
  • Settings pages ([[settings.pages]] and [[settings.pages.rows]]): pages under Settings › Modules › <name>, with the same fixed layout as the rest of Settings. Up to 8 pages and 48 rows per module.
  • Events ([[hooks]]): launcher.started, focus.system / focus.game, settings.changed and game.ended.

Row types for Settings pages:

typebehaviorfields
toggleswitch; writes keykey, default (bool)
value‹ › between choiceskey, choices, labels (i18n keys), default
slider‹ › numerickey, min, max, step, default
checkbox checked with Akey, default
actionA sends action.invoke; the row shows "…" until the replyaction, confirm (optional i18n key → dialog)
textread-only row; the value comes from ui.updateid, initial value
keyboardopens the launcher's keyboard; writes keykey, max (≤ 64), secret (masked, never logged)

Common fields: label, subtitle, section (heading above the row). The launcher owns the values: it writes module-settings/<id>.json, passes it in initialize and announces changes with settings.changed. The module can ask for settings.set only on keys it declared.

Phase 2: intercepting a game launch (launch.before: approve, change core/emulator or CPU governor, or cancel), list filters, virtual collections, replaceable slots (start.update, quick.details), config_write, game.launch and the suspend.*, scrape.done and launch.after events.

Phase 3: module screens built from theme templates (list, detail) and a metadata provider for the scraper.

There is no access to the launcher's internal state, raw input or pixels, and no way to replace whole launcher screens.


Permissions and sandbox

Nothing from a module runs as root. The sandbox hides the whole /storage partition and binds back only the module's own folder, its data folder and its settings file. /sys and /proc stay readable (battery, CPU, memory); root processes are hidden. Memory is capped by limits.memory_mb, CPU is shared by weight (CPUWeight=20, Nice=10).

Permissions are declared in the manifest and shown before installing:

permissioneffectshown to the user
(none)own folder, /sys and /proc read-only"No special access"
net = truenetwork allowed (otherwise PrivateNetwork)"Accesses the internet"
read = ["roms", "bios", "screenshots"]read-only bind of those aliases, never a free path"Reads your ROMs" …
write = ["screenshots"]writable bind of the alias"Changes your screenshots"
config_read = [...]config.get for the listed keys only; secrets (passwords, tokens, PSK) never"Reads settings: …"
config_write = [...]config.set through archr-config, closed list of non-sensitive keys"Changes settings: …" (amber)
hooks = ["launch"]receives launch.before and can cancel or change it"Can change or block game launches" (amber)
system = ["cpu-profile"]changes.governor accepted"Changes the CPU profile of games"
launch = truegame.launch {game_key} (library games only)"Can open games"

The first version offers no permission, read, net and config_read; the others arrive with phase 2.

Failure isolation: initialize has 3 s, action.invoke 1 s (the module may answer {"pending":true} and finish later with ui.notify), launch.before 2 s. A timeout, invalid JSON, a crash, running out of memory or too much CPU counts as a strike; 3 strikes in 10 minutes disable the module until the user turns it on again. Lines are limited to 64 KiB, ui.update to 4 per second and ui.notify to one every 10 s. Stderr goes to the unit's journal.


Protocol

JSON-RPC 2.0, one UTF-8 object per line, on the module's stdin/stdout. Every message has "jsonrpc": "2.0". Stderr is the log.

Environment: ARCHR_API_VERSION, ARCHR_LAUNCHER_VERSION, ARCHR_MODULE_ID, ARCHR_MODULE_DIR (code, read-only), ARCHR_MODULE_DATA (= HOME, writable), ARCHR_MODULE_SETTINGS (settings JSON, read-only), ARCHR_LANG, ARCHR_DEVICE.

Launcher → module, requests (with id, need a reply):

methodparamsresult
initialize{api_version, launcher_version, language, device, settings, permissions}{ready: true}
shutdown{}{} (then exit)
action.invoke{action, context:{system?, game_key?, rom?}}{} or {pending:true}
launch.before (phase 2){game:{system, rom, name, key, core, emulator}, netplay}{decision, changes?, message?}

Launcher → module, notifications (no id): events, as {"method":"event","params":{"name":"focus.game","data":{...}}}.

Module → launcher:

methodtypeparams
ui.updatenotification{target: "tile:<id>" | "shortcut:<id>" | "row:<id>" | "quick:<id>", value?, hidden?}
ui.notifynotificationtext or key/args, level: "info" | "error" → notice banner
settings.setrequest{key, value} (keys from its own manifest only)
config.get / config.setrequest{key} / {key, value} (needs the permission)
lognotification{level, message}

In oneshot mode the launcher writes one message (request or event) and closes stdin; the module may emit notifications and, if there was an id, exactly one reply. There is no initialize: settings come from ARCHR_MODULE_SETTINGS.

Errors (error: {code, message}): -32700 invalid JSON, -32600 invalid request, -32601 unknown method, -32602 invalid params, -32001 permission denied, -32002 timeout, -32003 rate limit, -32004 not found. A module must answer -32601 to methods it does not know; the launcher treats that as "not implemented", with no strike.


Example 1: "Bateria detalhada" (bash + jq, daemon)

A Start menu tile with charge and power, and a page with battery health, a switch and a refresh interval. No permissions: it only reads /sys.

module.toml

id = "bateria-detalhada"
version = "1.0.0"
api_version = 1
min_launcher = "1.0.0"
name = "@name"
description = "@description"
author = "Fulano <fulano@exemplo.org>"
license = "MIT"
homepage = "https://github.com/fulano/archr-bateria"
icon = "battery-full"

[run]
runtime = "bash"
entry = "main.sh"
mode = "daemon"

[limits]
memory_mb = 24

[[start.tiles]]
id = "bateria"
label = "@name"
icon = "battery-full"
open = "main"

[[settings.pages]]
id = "main"
title = "@name"

[[settings.pages.rows]]
type = "text"
id = "agora"
label = "@row.now"
section = "@section.state"

[[settings.pages.rows]]
type = "text"
id = "saude"
label = "@row.health"

[[settings.pages.rows]]
type = "toggle"
key = "watts"
label = "@row.watts"
section = "@section.options"
default = true

[[settings.pages.rows]]
type = "value"
key = "intervalo"
label = "@row.interval"
choices = ["5", "15", "30"]
labels = ["@every.5", "@every.15", "@every.30"]
default = "15"

[[settings.pages.rows]]
type = "action"
action = "atualizar"
label = "@row.refresh"

locales/pt_BR.toml (the other three languages use the same keys)

"name" = "Bateria detalhada"
"description" = "Carga, potência e saúde da bateria no menu Start."
"section.state" = "Estado"
"section.options" = "Opções"
"row.now" = "Agora"
"row.health" = "Saúde"
"row.watts" = "Mostrar potência"
"row.interval" = "Atualizar a cada"
"row.refresh" = "Atualizar agora"
"every.5" = "5 segundos"
"every.15" = "15 segundos"
"every.30" = "30 segundos"
"st.Charging" = "{pct} % · carregando"
"st.Discharging" = "{pct} %"
"st.Full" = "{pct} % · completa"
"st.watts" = "{pct} % · {w} W"

main.sh

#!/bin/bash
# Bateria detalhada: archr-launcher daemon module (API 1), bash + jq.
PS=/sys/class/power_supply
watts=true
interval=15

send() { printf '%s\n' "$1"; }

battery() {
    for d in "$PS"/*; do
        if [ "${d##*/}" = battery ] || [ "$(cat "$d/type" 2>/dev/null)" = Battery ]; then
            echo "$d"
            return
        fi
    done
}

update() {
    local d pct status ua uv w now target full design health
    d=$(battery)
    [ -n "$d" ] || return
    pct=$(cat "$d/capacity" 2>/dev/null || echo 0)
    status=$(cat "$d/status" 2>/dev/null || echo Discharging)
    ua=$(cat "$d/current_now" 2>/dev/null)
    uv=$(cat "$d/voltage_now" 2>/dev/null)
    if [ "$watts" = true ] && [ -n "$ua" ] && [ -n "$uv" ] && [ "$status" = Discharging ]; then
        w=$(awk -v a="$ua" -v v="$uv" 'BEGIN { p = a * v / 1e12; if (p < 0) p = -p; printf "%.1f", p }')
        now=$(jq -cn --argjson pct "$pct" --arg w "$w" '{key: "@st.watts", args: {pct: $pct, w: $w}}')
    else
        case $status in Charging | Full) ;; *) status=Discharging ;; esac
        now=$(jq -cn --argjson pct "$pct" --arg k "@st.$status" '{key: $k, args: {pct: $pct}}')
    fi
    for target in tile:bateria row:agora; do
        send "$(jq -cn --arg t "$target" --argjson v "$now" \
            '{jsonrpc: "2.0", method: "ui.update", params: {target: $t, value: $v}}')"
    done
    full=$(cat "$d/charge_full" 2>/dev/null)
    design=$(cat "$d/charge_full_design" 2>/dev/null)
    if [ -n "$full" ] && [ "${design:-0}" -gt 0 ]; then
        health="$((100 * full / design)) %"
    else
        health="—"
    fi
    send "$(jq -cn --arg h "$health" \
        '{jsonrpc: "2.0", method: "ui.update", params: {target: "row:saude", value: $h}}')"
}

reply() { send "$(jq -cn --argjson id "$1" --argjson r "$2" '{jsonrpc: "2.0", id: $id, result: $r}')"; }

handle() {
    local method id key value
    method=$(jq -r '.method // empty' <<<"$1")
    id=$(jq -c '.id // null' <<<"$1")
    case $method in
        initialize)
            watts=$(jq -r '.params.settings.watts // true' <<<"$1")
            interval=$(jq -r '.params.settings.intervalo // "15"' <<<"$1")
            reply "$id" '{"ready": true}'
            update
            ;;
        event)
            if [ "$(jq -r '.params.name' <<<"$1")" = settings.changed ]; then
                key=$(jq -r '.params.data.key' <<<"$1")
                value=$(jq -r '.params.data.value' <<<"$1")
                case $key in
                    watts) watts=$value ;;
                    intervalo) interval=$value ;;
                esac
                update
            fi
            ;;
        action.invoke)
            [ "$(jq -r '.params.action' <<<"$1")" = atualizar ] && update
            reply "$id" '{}'
            ;;
        shutdown)
            reply "$id" '{}'
            exit 0
            ;;
        *)
            [ "$id" = null ] || send "$(jq -cn --argjson id "$id" --arg m "$method" \
                '{jsonrpc: "2.0", id: $id, error: {code: -32601, message: $m}}')"
            ;;
    esac
}

while true; do
    if IFS= read -r -t "$interval" line; then
        handle "$line"
    elif [ $? -gt 128 ]; then
        update          # nothing arrived within the interval
    else
        exit 0          # EOF: the launcher closed stdin
    fi
done

Example 2: "Perfil de CPU por sistema" (sh + jq, oneshot, phase 2)

Picks the CPU governor for each game launch by system. It needs launch.before and the --governor option for runemu.sh, which arrive with phase 2.

module.toml

id = "perfil-cpu"
version = "1.0.0"
api_version = 1
min_launcher = "1.1.0"          # the version that brings launch.before and --governor
name = "@name"
description = "@description"
author = "Fulano <fulano@exemplo.org>"
license = "MIT"
homepage = "https://github.com/fulano/archr-perfil-cpu"
icon = "cpu"

[run]
runtime = "sh"
entry = "main.sh"
mode = "oneshot"

[permissions]
hooks = ["launch"]
system = ["cpu-profile"]

[[hooks]]
event = "launch.before"

[[settings.pages]]
id = "main"
title = "@name"

[[settings.pages.rows]]
type = "value"
key = "pesados"
label = "@row.heavy"
subtitle = "@row.heavy_sub"
choices = ["performance", "schedutil"]
labels = ["@gov.performance", "@gov.schedutil"]
default = "performance"

[[settings.pages.rows]]
type = "value"
key = "leves"
label = "@row.light"
subtitle = "@row.light_sub"
choices = ["ondemand", "powersave"]
labels = ["@gov.ondemand", "@gov.powersave"]
default = "powersave"

main.sh

#!/bin/sh
# One message per run: one JSON line in, at most one reply out.
IFS= read -r msg || exit 0
eval "$(printf '%s' "$msg" | jq -r '@sh "id=\(.id // "null") method=\(.method) system=\(.params.game.system // "")"')"

reply() { jq -cn --argjson id "$id" --argjson result "$1" '{jsonrpc:"2.0",id:$id,result:$result}'; }

if [ "$method" != launch.before ]; then
    [ "$id" = null ] || jq -cn --argjson id "$id" \
        '{jsonrpc:"2.0",id:$id,error:{code:-32601,message:"method"}}'
    exit 0
fi

setting() {
    value=$(jq -r --arg k "$1" '.[$k] // empty' "$ARCHR_MODULE_SETTINGS" 2>/dev/null)
    printf '%s' "${value:-$2}"
}

case "$system" in
    n64|psp|dreamcast|saturn|nds|psx) governor=$(setting pesados performance) ;;
    nes|gb|gbc|gamegear|mastersystem|atari2600|pcengine) governor=$(setting leves powersave) ;;
    *) reply '{"decision":"approve"}'; exit 0 ;;
esac
reply "$(jq -cn --arg g "$governor" '{decision:"modify",changes:{governor:$g}}')"

The launcher checks governor against a closed list and passes --governor=<g> to runemu.sh for that run only; the user's own CPU governor setting is not changed.


Testing (planned)

  • On a PC: archr-launcher --modules-dir <folder> and archr-launcher --check-module <folder> (checks the manifest, locales, Lucide icons and sizes; exits non-zero with the list of problems). Without systemd-run the process runs unsandboxed, with a warning in the log.
  • On the device: copy the folder to /storage/.config/archr-launcher/modules/<id>/ over SSH and turn it on in Settings › Modules. Errors: journalctl -u archr-mod-<id>.
  • A oneshot by hand: echo '{"jsonrpc":"2.0","id":1,"method":"launch.before","params":{"game":{"system":"n64"}}}' | ARCHR_MODULE_SETTINGS=/dev/null sh main.sh

Module Store (planned)

The Module Store reuses the Theme Store's machinery and adds a permissions screen.

  • Discovery: public GitHub repositories with the topic archr-launcher-module. Modules reviewed by the ArchR project get a "Reviewed by ArchR" badge and are listed first; the badge goes away if the reviewed release changes.
  • Release rules: the latest non-pre-release, tagged v<version> matching the manifest's version, with three assets: <id>-<version>.tar.gz, a loose copy of module.toml and preview.png. Tarball up to 8 MB, unpacked up to 16 MB, at most 500 entries, everything inside <id>/.
  • Verification: the loose module.toml is downloaded first, to show name and permissions without fetching the package. After the download the store checks the SHA-256 digest GitHub publishes for the asset and requires the tarball's module.toml to be byte-identical to the loose one. Symbolic links, hard links, devices, absolute paths and .. are refused.
  • Install: a screen with name, author, version, preview and the permissions list, then Install / Cancel.
  • Updates are never automatic. When permissions grow, only the new ones are shown and consent is asked again. The previous version is kept until the new one initializes successfully.
  • Turn on/off and remove: Settings › Modules, one switch per module; removing asks whether to delete the module's data and settings too.
  • Local install for developers: copy the folder to modules/<id>/ over SSH; it appears with a "Local" badge and the same permissions screen on first use.

On this page