Criar módulos (prévia)
Rascunho da especificação de módulos do archr-launcher - modelo de execução, module.toml, permissões, pontos de extensão, protocolo JSON-RPC e dois exemplos.
Em desenvolvimento. Os módulos ainda não estão implementados. Esta página resume o desenho atual para quem quiser acompanhar; nomes, campos e limites ainda podem mudar antes da primeira versão. Nada daqui funciona no aparelho hoje.
Um módulo é um programa externo, em qualquer linguagem que a imagem rode, que o archr-launcher inicia numa sandbox e com quem conversa por JSON. O módulo declara o que acrescenta (blocos no menu Start, itens no menu rápido, páginas em Configurações, ganchos de eventos) num module.toml; o launcher desenha tudo com os próprios componentes. O módulo nunca desenha pixels, nunca lê o controle diretamente e nunca roda como root.
Modelo de execução
Cada módulo é um processo separado que fala JSON-RPC 2.0, um objeto por linha (NDJSON), pelo stdin e stdout. O launcher o inicia com systemd-run --pipe como o usuário sem privilégios archr-mod, com sandbox e limites de cgroup definidos pelo systemd.
Dois modos, declarados no manifesto:
| modo | vida do processo | uso típico | custo |
|---|---|---|---|
oneshot | um processo por mensagem: uma linha entra, zero ou mais linhas saem, e ele termina | ganchos e ações raras | nada parado na RAM; cada chamada paga a partida do processo |
daemon | um processo enquanto o launcher estiver aberto | valores ao vivo (bateria), filtros, telas | um processo residente mais o systemd-run que espera por ele |
Medido num R36S, iniciar um systemd-run --pipe com sandbox leva cerca de 190 ms, e uma chamada simples de jq cerca de 26 ms. oneshot serve para ações do usuário (abrir uma página, apertar um item); eventos frequentes, como trocar o jogo selecionado, devem ir para um daemon.
Regras de vida:
- Nenhum módulo roda durante o jogo. Antes de entregar a tela a um jogo, o launcher para todos os módulos (500 ms, depois o cgroup é morto). Quando o launcher volta, o módulo recebe o evento
game.ended. - Daemons sobem depois do primeiro quadro e da biblioteca carregada, um por vez, com
Nice=10. Nunca atrasam o boot. - Fim do stdin (EOF) significa que o módulo deve sair. O launcher manda
shutdownantes. - No máximo 3 daemons ao mesmo tempo; os demais módulos têm de ser
oneshot.
Linguagens
Os ambientes garantidos são shell (sh/bash) com jq e binários nativos aarch64 (Rust, Go, C). O Python pode existir na imagem, mas um módulo não pode depender dele.
Estrutura do pacote
<id>/
module.toml obrigatório
main.sh | bin/<binário aarch64>
locales/en.toml obrigatório; pt_BR.toml, es.toml, ru.toml recomendados
icons/<nome>.svg SVGs Lucide sem modificação, só os usados
preview.png 640×480, obrigatório para a Loja de módulos
LICENSE
README.mdOs módulos instalados ficam em /storage/.config/archr-launcher/modules/<id>/. Cada módulo também ganha uma pasta de dados gravável, /storage/.config/archr-launcher/module-data/<id>/, e uma cópia só de leitura dos seus ajustes, /storage/.config/archr-launcher/module-settings/<id>.json, que só o launcher escreve.
module.toml
id = "bateria-detalhada" # [a-z0-9-]{3,40}, único no aparelho
version = "1.0.0" # semver
api_version = 1 # versão do protocolo e do manifesto
min_launcher = "1.0.0"
name = "@name" # "@" = chave do locales/<idioma>.toml do módulo
description = "@description"
author = "Fulano <fulano@exemplo.org>"
license = "MIT"
homepage = "https://github.com/fulano/archr-bateria"
icon = "battery-full" # nome Lucide; o SVG vem em icons/
[run]
runtime = "bash" # "sh" | "bash" | "native"
entry = "main.sh"
mode = "daemon" # "daemon" | "oneshot"
[limits]
memory_mb = 24 # padrão 32, máximo 64
[permissions] # tudo opcional; ausente = negado
# net = false
# read = ["roms"]
# hooks = ["launch"]
[[hooks]]
event = "focus.game" # assinatura de eventos
# pontos de extensão: [[start.tiles]], [[start.shortcuts]], [[quick]],
# [[settings.pages]] + [[settings.pages.rows]], [[screens]], [[collections]],
# [[replace]], [provider.metadata]Chaves desconhecidas e tipos errados geram aviso e são ignorados, como nos temas. Só são fatais id, version, api_version, run e uma permissão desconhecida, para o usuário saber exatamente o que concede.
Traduções: locales/<idioma>.toml usa o mesmo formato plano chave = "texto" dos catálogos do launcher (en, pt_BR, es, ru). Rótulos escritos como @chave são procurados no catálogo do módulo, com reserva em en. Textos dinâmicos podem vir prontos (text) ou como {"key": "@status.charging", "args": {...}} para o launcher formatar.
Ícones: só Lucide. icon = "<nome>" e o SVG sem modificação em icons/<nome>.svg; o validador compara com o conjunto Lucide 0.468.0 e recusa qualquer outro.
Pontos de extensão
Tudo é declarado no manifesto, para o launcher mostrar sem iniciar o módulo; só o conteúdo dinâmico (valores, listas, decisões) vem do processo. O desenho está dividido em fases.
Fase 1 (primeira versão):
- Blocos no menu Start (
[[start.tiles]]): bloco na fileira de cima, até 2 por módulo e 4 no total, comvaluevivo opcional atualizado porui.update. Atalhos ([[start.shortcuts]]): 1 por módulo, 2 no total, na fileira de baixo antes de Suspender. Ao selecionar, abre uma página do módulo (open = "<página>") ou envia uma ação (action = "<nome>"). - Item no menu rápido (Y) (
[[quick]]):context = "game"ou"system", filtro opcionalsystems = [...]e umaaction, inserido antes de Suspender. A ação recebe{system, game_key, rom}(jogo) ou{system}(sistema). Um item por módulo, três por menu. - Páginas em Configurações (
[[settings.pages]]e[[settings.pages.rows]]): páginas em Configurações › Módulos › <nome>, com o mesmo layout fixo do resto de Configurações. Até 8 páginas e 48 linhas por módulo. - Eventos (
[[hooks]]):launcher.started,focus.system/focus.game,settings.changedegame.ended.
Tipos de linha das páginas:
type | comportamento | campos |
|---|---|---|
toggle | interruptor; grava key | key, default (bool) |
value | ‹ › entre escolhas | key, choices, labels (chaves i18n), default |
slider | ‹ › numérico | key, min, max, step, default |
check | caixa marcada com A | key, default |
action | A envia action.invoke; a linha mostra "…" até a resposta | action, confirm (chave i18n opcional → diálogo) |
text | linha só de leitura; o valor vem de ui.update | id, value inicial |
keyboard | abre o teclado do launcher; grava key | key, max (≤ 64), secret (mascarado, nunca logado) |
Campos comuns: label, subtitle, section (cabeçalho acima da linha). Os valores são do launcher: ele grava module-settings/<id>.json, passa o conteúdo no initialize e avisa mudanças com settings.changed. O módulo só pode pedir settings.set para chaves que ele mesmo declarou.
Fase 2: interceptar o lançamento de um jogo (launch.before: aprovar, trocar núcleo/emulador ou governador da CPU, ou cancelar), filtros de lista, coleções virtuais, slots substituíveis (start.update, quick.details), config_write, game.launch e os eventos suspend.*, scrape.done e launch.after.
Fase 3: telas de módulo montadas com modelos do tema (list, detail) e um provedor de metadados para o scraper.
Não existe acesso ao estado interno do launcher, à entrada bruta nem a pixels, nem forma de substituir telas inteiras do launcher.
Permissões e sandbox
Nada do módulo roda como root. A sandbox esconde toda a partição /storage e liga de volta só a pasta do módulo, a pasta de dados e o arquivo de ajustes. /sys e /proc ficam legíveis (bateria, CPU, memória); processos do root ficam invisíveis. A memória é limitada por limits.memory_mb, e a CPU é dividida por peso (CPUWeight=20, Nice=10).
As permissões são declaradas no manifesto e mostradas antes de instalar:
| permissão | efeito | texto ao usuário |
|---|---|---|
| (nenhuma) | só a própria pasta, /sys e /proc em leitura | "Sem acesso especial" |
net = true | rede liberada (senão PrivateNetwork) | "Acessa a internet" |
read = ["roms", "bios", "screenshots"] | bind só leitura desses apelidos, nunca caminho livre | "Lê suas ROMs" … |
write = ["screenshots"] | bind gravável do apelido | "Altera suas capturas" |
config_read = [...] | config.get só nas chaves listadas; segredos (senhas, tokens, PSK) nunca | "Lê ajustes: …" |
config_write = [...] | config.set via archr-config, lista fechada de chaves não sensíveis | "Altera ajustes: …" (âmbar) |
hooks = ["launch"] | recebe launch.before e pode cancelar ou alterar | "Pode alterar ou impedir a abertura de jogos" (âmbar) |
system = ["cpu-profile"] | changes.governor aceito | "Muda o perfil de CPU dos jogos" |
launch = true | game.launch {game_key} (só jogos da biblioteca) | "Pode abrir jogos" |
A primeira versão oferece nenhuma permissão, read, net e config_read; as outras chegam na fase 2.
Isolamento de falhas: initialize tem 3 s, action.invoke 1 s (o módulo pode responder {"pending":true} e concluir depois com ui.notify), launch.before 2 s. Tempo esgotado, JSON inválido, queda, falta de memória ou CPU demais contam uma punição; 3 punições em 10 minutos desativam o módulo até o usuário religar. Linhas têm até 64 KiB, ui.update até 4 por segundo e ui.notify um a cada 10 s. O stderr vai para o journal da unidade.
Protocolo
JSON-RPC 2.0, um objeto UTF-8 por linha, no stdin/stdout do módulo. Toda mensagem tem "jsonrpc": "2.0". O stderr é o log.
Ambiente: ARCHR_API_VERSION, ARCHR_LAUNCHER_VERSION, ARCHR_MODULE_ID, ARCHR_MODULE_DIR (código, só leitura), ARCHR_MODULE_DATA (= HOME, gravável), ARCHR_MODULE_SETTINGS (JSON dos ajustes, só leitura), ARCHR_LANG, ARCHR_DEVICE.
Launcher → módulo, pedidos (têm id, exigem resposta):
| método | params | result |
|---|---|---|
initialize | {api_version, launcher_version, language, device, settings, permissions} | {ready: true} |
shutdown | {} | {} (depois sair) |
action.invoke | {action, context:{system?, game_key?, rom?}} | {} ou {pending:true} |
launch.before (fase 2) | {game:{system, rom, name, key, core, emulator}, netplay} | {decision, changes?, message?} |
Launcher → módulo, notificações (sem id): eventos, como {"method":"event","params":{"name":"focus.game","data":{...}}}.
Módulo → launcher:
| método | tipo | params |
|---|---|---|
ui.update | notificação | {target: "tile:<id>" | "shortcut:<id>" | "row:<id>" | "quick:<id>", value?, hidden?} |
ui.notify | notificação | texto ou key/args, level: "info" | "error" → faixa de aviso |
settings.set | pedido | {key, value} (só chaves do próprio manifesto) |
config.get / config.set | pedido | {key} / {key, value} (exige a permissão) |
log | notificação | {level, message} |
No modo oneshot, o launcher escreve uma mensagem (pedido ou evento) e fecha o stdin; o módulo pode emitir notificações e, se havia id, exatamente uma resposta. Não há initialize: os ajustes vêm de ARCHR_MODULE_SETTINGS.
Erros (error: {code, message}): -32700 JSON inválido, -32600 pedido inválido, -32601 método desconhecido, -32602 parâmetros inválidos, -32001 permissão negada, -32002 tempo esgotado, -32003 limite de taxa, -32004 não encontrado. O módulo deve responder -32601 a métodos que não conhece; o launcher trata como "não implementa", sem punição.
Exemplo 1: "Bateria detalhada" (bash + jq, daemon)
Bloco no menu Start com carga e potência, e uma página com a saúde da bateria, um interruptor e um intervalo de atualização. Nenhuma permissão: só lê /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 (os outros três idiomas com as mesmas chaves)
"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
doneExemplo 2: "Perfil de CPU por sistema" (sh + jq, oneshot, fase 2)
Escolhe o governador da CPU de cada lançamento de jogo conforme o sistema. Depende do launch.before e da opção --governor do runemu.sh, que chegam na fase 2.
module.toml
id = "perfil-cpu"
version = "1.0.0"
api_version = 1
min_launcher = "1.1.0" # versão que traz launch.before e --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
# Uma mensagem por execução: uma linha JSON entra, no máximo uma resposta sai.
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}}')"O launcher confere governor contra uma lista fechada e passa --governor=<g> ao runemu.sh só nessa execução; o ajuste de governador do usuário não muda.
Testar (planejado)
- No PC:
archr-launcher --modules-dir <pasta>earchr-launcher --check-module <pasta>(confere manifesto, locales, ícones Lucide e tamanhos; sai com código diferente de zero e a lista de problemas). Semsystemd-run, o processo roda sem sandbox, com aviso no log. - No aparelho: copie a pasta para
/storage/.config/archr-launcher/modules/<id>/por SSH e ligue em Configurações › Módulos. Erros:journalctl -u archr-mod-<id>. - Um
oneshotà mão:echo '{"jsonrpc":"2.0","id":1,"method":"launch.before","params":{"game":{"system":"n64"}}}' | ARCHR_MODULE_SETTINGS=/dev/null sh main.sh
Loja de módulos (planejada)
A Loja de módulos reaproveita a estrutura da Loja de temas e acrescenta a tela de permissões.
- Descoberta: repositórios públicos do GitHub com o tópico
archr-launcher-module. Os módulos revisados pelo projeto ArchR ganham o selo "Revisado pelo ArchR" e aparecem primeiro; o selo some se a release revisada mudar. - Regras de release: a última release que não é pre-release, com tag
v<version>igual aoversiondo manifesto e três assets:<id>-<version>.tar.gz, uma cópia avulsa domodule.tomlepreview.png. Tarball até 8 MB, descompactado até 16 MB, no máximo 500 entradas, tudo dentro de<id>/. - Verificação: o
module.tomlavulso é baixado primeiro, para mostrar nome e permissões sem baixar o pacote. Depois do download, a loja confere o digest SHA-256 que o GitHub publica para o asset e exige que omodule.tomldo tarball seja idêntico byte a byte ao avulso. Links simbólicos, hard links, dispositivos, caminhos absolutos e..são recusados. - Instalar: tela com nome, autor, versão, preview e a lista de permissões, depois Instalar / Cancelar.
- Atualizações nunca são automáticas. Se as permissões crescerem, só as novas aparecem e o consentimento é pedido de novo. A versão anterior fica guardada até a nova iniciar com sucesso.
- Ligar/desligar e remover: Configurações › Módulos, um interruptor por módulo; ao remover, a loja pergunta se apaga também os dados e os ajustes do módulo.
- Instalação local para devs: copie a pasta para
modules/<id>/por SSH; ela aparece com o selo "Local" e a mesma tela de permissões no primeiro uso.