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:
| mode | process lifetime | typical use | cost |
|---|---|---|---|
oneshot | one process per message: one line in, zero or more lines out, then it exits | hooks and rare actions | nothing resident in RAM; each call pays the process start |
daemon | one process while the launcher is open | live values (battery), filters, screens | one 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.endedevent. - 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
shutdownfirst. - 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.mdInstalled 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 livevalueupdated byui.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 optionalsystems = [...]filter and anaction, 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.changedandgame.ended.
Row types for Settings pages:
type | behavior | fields |
|---|---|---|
toggle | switch; writes key | key, default (bool) |
value | ‹ › between choices | key, choices, labels (i18n keys), default |
slider | ‹ › numeric | key, min, max, step, default |
check | box checked with A | key, default |
action | A sends action.invoke; the row shows "…" until the reply | action, confirm (optional i18n key → dialog) |
text | read-only row; the value comes from ui.update | id, initial value |
keyboard | opens the launcher's keyboard; writes key | key, 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:
| permission | effect | shown to the user |
|---|---|---|
| (none) | own folder, /sys and /proc read-only | "No special access" |
net = true | network 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 = true | game.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):
| method | params | result |
|---|---|---|
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:
| method | type | params |
|---|---|---|
ui.update | notification | {target: "tile:<id>" | "shortcut:<id>" | "row:<id>" | "quick:<id>", value?, hidden?} |
ui.notify | notification | text or key/args, level: "info" | "error" → notice banner |
settings.set | request | {key, value} (keys from its own manifest only) |
config.get / config.set | request | {key} / {key, value} (needs the permission) |
log | notification | {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
doneExample 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>andarchr-launcher --check-module <folder>(checks the manifest, locales, Lucide icons and sizes; exits non-zero with the list of problems). Withoutsystemd-runthe 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
oneshotby 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'sversion, with three assets:<id>-<version>.tar.gz, a loose copy ofmodule.tomlandpreview.png. Tarball up to 8 MB, unpacked up to 16 MB, at most 500 entries, everything inside<id>/. - Verification: the loose
module.tomlis 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'smodule.tomlto 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.