ARCH R

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:

modovida do processouso típicocusto
oneshotum processo por mensagem: uma linha entra, zero ou mais linhas saem, e ele terminaganchos e ações rarasnada parado na RAM; cada chamada paga a partida do processo
daemonum processo enquanto o launcher estiver abertovalores ao vivo (bateria), filtros, telasum 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 shutdown antes.
  • 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.md

Os 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, com value vivo opcional atualizado por ui.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 opcional systems = [...] e uma action, 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.changed e game.ended.

Tipos de linha das páginas:

typecomportamentocampos
toggleinterruptor; grava keykey, default (bool)
value‹ › entre escolhaskey, choices, labels (chaves i18n), default
slider‹ › numéricokey, min, max, step, default
checkcaixa marcada com Akey, default
actionA envia action.invoke; a linha mostra "…" até a respostaaction, confirm (chave i18n opcional → diálogo)
textlinha só de leitura; o valor vem de ui.updateid, value inicial
keyboardabre o teclado do launcher; grava keykey, 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ãoefeitotexto ao usuário
(nenhuma)só a própria pasta, /sys e /proc em leitura"Sem acesso especial"
net = truerede 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 = truegame.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étodoparamsresult
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étodotipoparams
ui.updatenotificação{target: "tile:<id>" | "shortcut:<id>" | "row:<id>" | "quick:<id>", value?, hidden?}
ui.notifynotificaçãotexto ou key/args, level: "info" | "error" → faixa de aviso
settings.setpedido{key, value} (só chaves do próprio manifesto)
config.get / config.setpedido{key} / {key, value} (exige a permissão)
lognotificaçã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
done

Exemplo 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> e archr-launcher --check-module <pasta> (confere manifesto, locales, ícones Lucide e tamanhos; sai com código diferente de zero e a lista de problemas). Sem systemd-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 ao version do manifesto e três assets: <id>-<version>.tar.gz, uma cópia avulsa do module.toml e preview.png. Tarball até 8 MB, descompactado até 16 MB, no máximo 500 entradas, tudo dentro de <id>/.
  • Verificação: o module.toml avulso é 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 o module.toml do 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.

On this page