{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://github.com/colabhd/vssh-sdk/api/vssh-app.schema.json",
  "title": "vssh-app.json",
  "description": "Manifesto de um vssh-app. A fonte deste schema é o repositório do sistema, `vssh-sso`, em `schema/`; o canal de publicação o copia para `api/` do `vssh-sdk`, onde o `vssh-app-publish` o lê e valida o manifesto por inteiro antes de o app entrar no repositório. O `vssh-app-install` valida só o mínimo, porque roda offline no servidor e não tem como buscar o schema. `additionalProperties: false` em todo objeto, a raiz inclusive: um campo desconhecido é recusado na publicação, porque `requiredPackage` (sem o s), `gpuu` ou `opns` publicariam limpos e seriam descartados em silêncio por quem lê. Um manifesto escrito para um SDK mais novo é recusado por um mais velho, e essa é a informação que interessa. Todo campo que o portal lê está declarado aqui, e `tests/unit/contrato-do-manifesto.test.js` do `vssh-sso` confere isso a cada campo novo.",
  "type": "object",
  "required": [
    "id",
    "version",
    "backend"
  ],
  "additionalProperties": false,
  "properties": {
    "id": {
      "type": "string",
      "pattern": "^[a-z0-9-]+$",
      "description": "Imutável — vira path, ENDEREÇO (~/.vssh-apps/<id>/app.sock) e sentinel. O endereço não é alocado: ele é DERIVADO de (HOME, id), e é por isso que trocar o id é publicar outro app."
    },
    "name": {
      "type": "string",
      "description": "Nome exibido. Default: o próprio id."
    },
    "version": {
      "type": "string",
      "pattern": "^[0-9A-Za-z][0-9A-Za-z.\\-+_]*$",
      "description": "Semver. O install é idempotente por versão, então bump a cada release."
    },
    "type": {
      "enum": [
        "app",
        "engine"
      ],
      "default": "app",
      "description": "'engine' = backend-only, sem janela nem ícone no Launchpad."
    },
    "kind": {
      "enum": [
        "app",
        "service"
      ],
      "default": "app",
      "description": "Eixo de LIFECYCLE, ortogonal a `type`: 'service' é daemon supervisionado (start automático, auto-restart, status). Um `type: engine` que oferece capacidade (`provides`) e não traz `engine.loader` sobe quando alguém pede a capacidade, e não no load do ambiente."
    },
    "escopo": {
      "enum": [
        "pessoa",
        "servidor"
      ],
      "default": "pessoa",
      "description": "Quantas instâncias o servidor roda. 'pessoa' é um backend por conta, como a pessoa, num socket em ~/.vssh-apps/<id>/. 'servidor' é um backend só para todas as contas do servidor: o `vssh-app-install` cria a conta de sistema `vssh-<id>` e a unit `vssh-app-<id>.service`, o socket fica em /run/vssh-apps/<id>/app.sock e o estado em /var/lib/vssh-apps/<id>/. O systemd sobe e relança a unit, e o portal não oferece start nem stop por pessoa. Pede `kind: 'service'` e um id de até 27 caracteres, que é o que cabe no nome da conta. O socket de um app de servidor aceita qualquer conta do servidor, então a autenticação é do app."
    },
    "engine": {
      "type": "object",
      "description": "Só para type:'engine'. Um motor pode trazer o PRÓPRIO lado-cliente, em vez de o ambiente carregar um arquivo que conhece o motor pelo nome.",
      "additionalProperties": false,
      "properties": {
        "loader": {
          "type": "string",
          "pattern": "^[A-Za-z0-9][A-Za-z0-9/._-]*\\.js$",
          "description": "Caminho, relativo à raiz servida pelo backend, de um script que o ambiente carrega depois de o backend responder. É ele que registra o motor (ex.: em RemoteDesktopEngines). Sem este campo o ambiente não carrega script nenhum do app — que é o caso de um motor cujo lado-cliente já mora no ambiente."
        }
      }
    },
    "provides": {
      "type": "array",
      "description": "Capacidades que este app oferece a OUTROS apps e ao ambiente, no formato 'nome/vN'. Declarar é o que permite trocar o produtor sem tocar em nenhum consumidor: um app de chat pede 'llm/v1' e recebe o motor que estiver instalado, seja ollama, vLLM ou outro. Sem isto o consumidor precisa FIXAR o appId no código — que é o que o shell fazia com o 'scramjet-wisp'. Declarar não é provar: o ambiente não verifica se o app cumpre a capacidade, pelo mesmo motivo que o registro de motores não verifica (exigir transforma 'este app não faz isso' em 'este app não carrega'). Quem não puder atender falha por dentro, que é onde sabe dizer por quê.",
      "items": {
        "type": "string",
        "pattern": "^[a-z][a-z0-9-]*/v[0-9]+$",
        "description": "'nome/vN'. A versão é OBRIGATÓRIA e é o ponto: uma capacidade é um contrato entre repositórios que não se conhecem, e um contrato sem versão só pode ser mudado quebrando alguém em silêncio. Trocar o contrato é publicar 'nome/v2' e conviver com o v1 enquanto houver consumidor."
      }
    },
    "requires": {
      "type": "array",
      "description": "Capacidades que este app exige de outros, no formato 'nome/vN': a metade que completa o `provides`. Um app de chat que precisa de um modelo declara `requires: [\"llm/v1\"]` e recebe o motor que estiver instalado, seja qual for. Quem confere é o portal, na instalação, contra o `provides` dos apps instalados naquele servidor: uma capacidade que ninguém oferece recusa a instalação nomeando-a, porque instalar um app que vai falhar por dentro no primeiro uso é pior que dizer o que falta. A conferência lê o catálogo do repositório, que é o único que responde antes de instalar; um catálogo sem este campo não confere. Só o nome com a versão, sem faixa nem comparação: uma capacidade é um contrato, e `nome/v2` é outro contrato.",
      "items": {
        "type": "string",
        "pattern": "^[a-z][a-z0-9-]*/v[0-9]+$",
        "description": "'nome/vN', o mesmo formato que o `provides` declara do outro lado."
      }
    },
    "usa": {
      "type": "array",
      "description": "Capacidades que este app usa quando algum app as oferece, no formato 'nome/vN': a forma opcional do `requires`. O Prelo cita da biblioteca do Zotero, e escreve sem ela. A instalação não confere este campo, e a ponte aceita a capacidade em `vssh.apps.pedir` e em `vssh.apps.listar` como aceita uma de `requires`. Sem quem ofereça, o `pedir` responde 404 com o nome da capacidade, e o `listar` responde a lista vazia.",
      "items": {
        "type": "string",
        "pattern": "^[a-z][a-z0-9-]*/v[0-9]+$",
        "description": "'nome/vN', o mesmo formato que o `provides` declara do outro lado."
      }
    },
    "capacidades": {
      "type": "object",
      "description": "Onde cada capacidade de `provides` é atendida, para outro app a chamar: o nome da capacidade leva ao prefixo das rotas do backend que a respondem. O Zotero declara `\"bibliography/v1\": { \"rota\": \"/bibliografia/v1\" }`. Outro app chama pela ponte (`vssh.apps.pedir(capacidade, caminho, opcoes)`): o shell acha quem oferece, pergunta à pessoa na primeira vez que este app usa aquele, sobe o app se estiver parado e faz o pedido como a mesma pessoa, só dentro do prefixo. Quem chama precisa declarar a capacidade em `requires` ou em `usa`. Com mais de um app oferecendo a mesma capacidade, quem chama os lista por `vssh.apps.listar(capacidade)` e escolhe um com `{ app }` no `pedir`; sem a escolha, o pedido vai ao primeiro, na ordem dos ids. O backend que responde recebe `X-Vssh-Chamador` com o id de quem chamou. Uma capacidade que não está em `provides` é ignorada, e uma de `provides` sem entrada aqui não se chama pela ponte.",
      "propertyNames": {
        "pattern": "^[a-z][a-z0-9-]*/v[0-9]+$"
      },
      "additionalProperties": {
        "type": "object",
        "required": [
          "rota"
        ],
        "additionalProperties": false,
        "properties": {
          "rota": {
            "type": "string",
            "pattern": "^(/[A-Za-z0-9._~-]+)+$",
            "not": {
              "pattern": "(^|/)\\.{1,2}(/|$)"
            },
            "description": "O prefixo das rotas, a partir da raiz do backend do app, sem barra no fim e sem `.` nem `..` como segmento. Um pedido pela ponte só alcança o que fica sob ele."
          },
          "rotulo": {
            "type": "string",
            "minLength": 1,
            "maxLength": 60,
            "description": "Como o app que chama nomeia esta oferta num menu, quando mais de um app oferece a capacidade: `Biblioteca do Zotero`. O `vssh.apps.listar` o devolve sem subir o app. Sem ele, quem chama mostra o nome do app."
          }
        }
      }
    },
    "minShellVersion": {
      "type": "string",
      "pattern": "^[0-9]+(\\.[0-9]+){0,2}$",
      "description": "Versão MÍNIMA do shell de que este app precisa. O padrão é NÃO declarar: um campo obrigatório transformaria toda API nova em quebra de compatibilidade declarada, que é burocracia sem benefício. Quem declara está dizendo 'eu uso uma coisa que não existia antes' — a mesma regra do `engines` do npm, pelo mesmo motivo. NÃO substitui o `vssh.capabilities()`: um é gate de instalação, o outro é decisão de runtime. Quem confere é o portal, que sabe a versão do shell que serve; o `vssh-app-install` roda offline no servidor e não tem como saber."
    },
    "contributes": {
      "type": "object",
      "description": "O que este app acrescenta às superfícies do AMBIENTE — coisas que não são a janela dele. Vale para qualquer app, e não só para `type:'engine'`. NÃO substitui `engine.loader`, e a diferença é de tamanho: o loader de um motor traz o lado-cliente INTEIRO (megabytes, assets, servidos pelo backend do app, que é um serviço e está de pé); uma contribuição é servida do DISCO pelo portal, então funciona com o backend desligado, que é o caso normal de um app comum. As duas formas que existem aqui são de naturezas diferentes de propósito — `settings` é CÓDIGO, `contextMenu` é DADO —, e o porquê está escrito em cada uma.",
      "additionalProperties": false,
      "properties": {
        "settings": {
          "type": "string",
          "pattern": "^[A-Za-z0-9][A-Za-z0-9/._-]*\\.js$",
          "description": "Caminho, relativo à raiz do PACOTE, de um script que registra uma seção em Configurações (`SettingsRegistry.register`). Carregado quando a janela de Configurações abre — não no boot: um app que ninguém configura não custa nada, e o ambiente não passa a depender de N apps para subir. ATENÇÃO ao que isto concede: o script roda na origem do shell, com a confiança do shell. O portão é o mesmo do resto do modelo — quem pode rodar `vssh-app-install`."
        },
        "settingsOptIn": {
          "type": "boolean",
          "default": false,
          "description": "A seção que `settings` traz só aparece em Configurações depois que a pessoa a liga de dentro do app, por `vssh.configuracoes.ligar(true)`; `vssh.configuracoes.ligada()` diz o estado, e `vssh.configuracoes.abrir()` abre a seção. É para o app cuja seção é exemplo ou ferramenta de quem escreve apps, e não preferência de quem o usa: os dois templates Hello World declaram. O app continua no menu iniciar, no Launchpad e em \"Abrir com\" como qualquer outro; só a seção espera o gesto. A escolha fica em `secoesDeApps`, por usuário, e acompanha a pessoa. Sem `settings`, não faz nada.",
          "examples": [true]
        },
        "contextMenu": {
          "type": "array",
          "maxItems": 8,
          "description": "Itens que este app põe no menu de contexto do AMBIENTE — clique direito num arquivo, numa pasta, na área de trabalho. É DADO, e não um caminho de script como `settings`, e a razão é o momento: Configurações abre porque alguém pediu e pode demorar; o menu de contexto abre no clique direito e não pode esperar fetch nenhum. O app DECLARA; quem monta o item, decide a ordem e executa é o shell — um app não roda uma linha na origem do shell para pôr um item de menu. Item inválido é OMITIDO em silêncio, e os irmãos válidos continuam: um item torto não pode impedir o menu de abrir. Teto de 8 por app (o teto da SOMA, entre todos os apps, é do shell).",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "id",
              "superficie",
              "rotulo"
            ],
            "properties": {
              "id": {
                "type": "string",
                "pattern": "^[a-z0-9][a-z0-9-]{0,47}$",
                "description": "Estável e único DENTRO deste app. Serve de desempate quando dois itens pedem a mesma `ordem`."
              },
              "superficie": {
                "type": "string",
                "enum": [
                  "arquivo",
                  "pasta",
                  "area-de-trabalho",
                  "icone-do-app"
                ],
                "description": "Onde o item aparece. `icone-do-app` é a JUMP LIST: o clique direito no ícone do app, no Launchpad e no Menu Iniciar — uma superfície só para os dois lugares, senão todo app teria de declarar o mesmo item duas vezes. Ela exige `rota` e recusa `quando`; as três de caminho exigem o contrário."
              },
              "rotulo": {
                "type": "string",
                "minLength": 1,
                "maxLength": 48,
                "description": "O texto do item. Nome de PRODUTO, na voz do ambiente ('Abrir no VSSHCode'), não jargão de manifesto. O teto de 48 não é segurança: é a largura do painel — um rótulo maior faz um menu que não cabe na tela."
              },
              "ordem": {
                "type": "integer",
                "minimum": 0,
                "maximum": 999,
                "default": 100,
                "description": "A POSIÇÃO no bloco de abertura, medida contra os itens do próprio shell — que é o que faz um item contribuído poder ficar ANTES de um embutido, em vez de sempre no fim atrás de um separador. A régua publicada: Abrir/Nova Pasta 10, Novo Arquivo 15, Abrir Terminal Aqui 20, Editor de Texto / Abrir em Arquivos 25, VS Code 30, Extrair aqui 35, Fixar na barra lateral 40, Abrir com 60, Baixar 70, Imprimir 80. O default 100 põe o item depois de todo embutido de abertura, que é onde um app novo pertence enquanto ninguém disser o contrário. Valor fora da faixa RECUSA o item, e não é aparado para o teto: quem pediu 10^9 estava pedindo para ficar sempre em último por um mecanismo que não foi escolhido."
              },
              "quando": {
                "type": "object",
                "additionalProperties": false,
                "description": "Restringe onde o item aparece. Só vale para `superficie: \"arquivo\"` — numa pasta ou na área de trabalho não há extensão, e um filtro que nunca casa é um item declarado, aceito e invisível (por isso ele é RECUSADO ali, em vez de ignorado).",
                "properties": {
                  "extensoes": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "string",
                      "pattern": "^\\.?[A-Za-z0-9][A-Za-z0-9+._-]*$"
                    },
                    "description": "Com ou sem ponto; normalizado pelo portal. Ausente = todo arquivo. É independente de `opens.extensions`: aquele campo diz o que o app sabe abrir (e o põe no submenu 'Abrir com'); este diz onde ele quer um item PRÓPRIO, de primeiro nível."
                  }
                }
              },
              "acao": {
                "type": "string",
                "enum": [
                  "abrir",
                  "abrirRota"
                ],
                "description": "O verbo, e ele é o da SUPERFÍCIE — não uma escolha livre. `abrir` (nas três superfícies de caminho) abre este app com o caminho clicado, pelo `open-context` que já é contrato publicado. `abrirRota` (só no `icone-do-app`) abre o app num lugar DELE, declarado em `rota`. Omitir o campo é aceitar o verbo da superfície; declarar o OUTRO recusa o item, porque um app que pediu outra coisa não quis esta — um `abrir` no ícone seria o item \"Abrir\" que aquele menu já tem, fixo, e um `abrirRota` num arquivo teria de decidir o que fazer com o caminho clicado."
              },
              "rota": {
                "type": "string",
                "minLength": 1,
                "maxLength": 512,
                "description": "O lugar DENTRO do app, colado depois da URL que o portal já resolveu para ele — `novo`, `docs/recentes?x=1`. Obrigatório com `superficie: \"icone-do-app\"`, e proibido nas outras. NÃO é URL: esquema (`https:`, `javascript:`), caminho absoluto e `..` são recusados pelo ambiente, com a mesma regra do `vssh.window.abrir` — sem ela uma rota viraria uma janela do shell servindo outra coisa, com o título e o ícone deste app. Com o app já aberto a rota chega pelo `open-context` (campo `rota`), porque quem sabe navegar é o app."
              }
            }
          }
        },
        "browserExtension": {
          "type": "object",
          "description": "A extensão do navegador embutido que é o PAR deste app — a que ele precisa do outro lado para funcionar inteiro (capturar de uma página, integrar com um site). Sem isto, um par app+extensão não tem como se declarar: o app é instalado por um admin com vssh-app-install, a extensão é instalada pelo usuário em vsshb://extensions, são catálogos diferentes e nada casa as versões — quem instala o app não descobre que falta metade. É DADO, como o `contextMenu` ao lado, e não um caminho de script: o app declara, e quem oferece é o ambiente. Declarar OFERECE; instalar continua sendo escolha do usuário, porque instalar uma extensão remota executa o bundle dela no contexto do portal, e isso não é decisão do app nem do admin.",
          "additionalProperties": false,
          "required": [
            "id"
          ],
          "properties": {
            "id": {
              "type": "string",
              "pattern": "^[a-z0-9-]+$",
              "description": "O `id` da extensão no catálogo de extensões (o mesmo `id` do vssh-ext.json dela). Só o id: a URL do bundle e a versão saem do catálogo no momento de oferecer, e repeti-las aqui seria uma segunda verdade sobre o mesmo fato — que envelhece sozinha a cada release da extensão."
            },
            "razao": {
              "type": "string",
              "minLength": 1,
              "maxLength": 120,
              "description": "Uma frase dizendo para que o app precisa dela, mostrada a quem for decidir. Sem isto o convite diz só o nome da extensão, e quem nunca ouviu falar dela não tem como responder."
            }
          }
        }
      }
    },
    "requiredPackages": {
      "type": "array",
      "description": "Pacotes Linux de que o app precisa para funcionar (ffmpeg, pandoc, rclone…). Declarar é o ponto: hoje uma dependência de sistema se esconde num installCommand opaco, e não há como responder 'este app roda neste servidor?' sem executá-lo. Cada item é uma EXIGÊNCIA, e uma exigência pode ter alternativas separadas por '|' — 'chromium | google-chrome-stable' quer dizer que qualquer um dos dois serve. Quem VERIFICA é o portal (vssh-app-install recusa antes de instalar, e o painel admin mostra o que falta por servidor).",
      "items": {
        "type": "string",
        "pattern": "^[a-z0-9][a-z0-9+.-]*( *\\| *[a-z0-9][a-z0-9+.-]*)*$",
        "description": "Uma exigência: um nome de pacote, ou vários separados por '|' quando qualquer um deles satisfaz — a ordem é a de PREFERÊNCIA, e é a primeira que as mensagens sugerem instalar. O alfabeto de cada nome é restritivo de propósito: este valor chega a um gerenciador de pacotes no servidor, e um nome com metacaractere de shell seria injeção. O '|' é a única exceção, e ele nunca atravessa como shell: quem confere separa as alternativas ANTES de montar o comando."
      }
    },
    "motores": {
      "type": "array",
      "description": "Artefatos grandes que o app usa e que não viajam no pacote: o servidor web de um editor, um ambiente Python com PyTorch, os pesos de um modelo. Cada motor é um tarball publicado à parte (no vssh-repo, pelo `publicar-motor` do vssh-sdk), e o servidor o baixa UMA vez, na fase root do `vssh-app-install`, para `/var/lib/vssh-motores/<app>/<nome>/<versao>`, fora do diretório do app: reinstalar o app na mesma versão do motor não baixa nada, e o `rsync` do instalador não o apaga. O sha256 é conferido antes de extrair, a troca é em dois passos (um download interrompido nunca parece instalado), e as versões que o manifesto deixou de declarar saem depois que a nova entra. Ao subir, o `vssh-app-run` exporta o caminho de cada motor em `VSSH_MOTOR_<NOME>` (maiúsculas, `-` vira `_`); um motor declarado sem `quando` que não está no disco recusa a subida, e o run.log diz o que pedir ao administrador.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["nome", "versao", "url", "sha256", "tamanho"],
        "properties": {
          "nome": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9-]{0,31}$",
            "description": "O nome do motor dentro do app, que dá nome à variável: `runtime` vira `VSSH_MOTOR_RUNTIME`. Minúsculas, porque ele também é um diretório no servidor."
          },
          "versao": {
            "type": "string",
            "pattern": "^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$",
            "description": "A versão do artefato. É ela que decide se o servidor baixa: a mesma versão já instalada não baixa de novo, e uma versão nova baixa e toma o lugar da antiga."
          },
          "url": {
            "type": "string",
            "pattern": "^https?://[^\\s]+$",
            "description": "De onde o servidor baixa o tarball, sem token: a rota `/v1/motor/<nome>/<versão>` do vssh-repo. O tarball tem um diretório na raiz, que a extração descarta."
          },
          "sha256": {
            "type": "string",
            "pattern": "^[0-9a-f]{64}$",
            "description": "O sha256 do tarball, conferido no servidor antes de extrair. Um tarball trocado no caminho não chega a escrever arquivo nenhum."
          },
          "tamanho": {
            "type": "integer",
            "minimum": 1,
            "description": "O tamanho do tarball em bytes. O instalador confere o espaço livre antes de baixar, e a loja o mostra antes de instalar."
          },
          "confere": {
            "type": "string",
            "pattern": "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*$",
            "description": "Um caminho relativo que o tarball extraído precisa trazer (`bin/python`, `bin/vssh-code-server`). Sem ele, a extração é recusada: a URL passou a servir outra coisa."
          },
          "quando": {
            "type": "string",
            "enum": ["gpu"],
            "description": "A condição para o servidor baixar o motor. Com `gpu`, o `vssh-app-install` só o baixa num servidor com uma placa física (o `vssh-gpu-info` responde por qualquer fabricante; uma placa virtual não conta), e num servidor sem placa ele não desce e uma versão que já estava no disco sai. O app sobe sem o motor, sem `VSSH_MOTOR_<NOME>`, e decide o que fazer sem ele (o Escriba transcreve pela fila do cluster). Sem o campo, o motor desce em todo servidor."
          }
        }
      }
    },
    "instalacao": {
      "type": "array",
      "description": "Onde o app se instala. `sistema` é `/opt/vssh-apps/<id>`, pelas mãos de um administrador, para todas as pessoas do servidor. `pessoa` é `~/.local/share/vssh-apps/<id>` de quem instala pela Loja sem ser administrador, só para essa pessoa. Sem o campo, vale `[\"sistema\"]`. Declarar `pessoa` é dizer que o app roda da HOME: o `installCommand` dele não precisa de root, e nada nele procura o próprio código em `/opt/vssh-apps`. Um app com `motores` só se instala no sistema, declare o que declarar, porque o motor mora numa pasta de root.",
      "items": { "type": "string", "enum": ["sistema", "pessoa"] },
      "examples": [["sistema", "pessoa"]]
    },
    "resources": {
      "type": "object",
      "description": "Limites de recurso do processo do app. O ambiente já aplica um teto PADRÃO a todo vssh-app (memória e número de tarefas) — este bloco existe para o app que precisa de outro, para mais ou para menos. Aplicado por `systemd-run --user --scope` no vssh-app-run, sobre o GRUPO de processos do app, então alcança os filhos que ele gerar. O que ele contém é UM app desgovernado, e não a soma deles: dois apps no teto ainda somam mais que a máquina.",
      "additionalProperties": false,
      "properties": {
        "memoryHigh": {
          "type": "string",
          "pattern": "^(none|[0-9]+(\\.[0-9]+)?(K|M|G|T|%)?)$",
          "description": "Onde o kernel começa a PRESSIONAR o app a devolver memória (systemd MemoryHigh). Não mata: reclama. É o limite que se quer ajustar primeiro, porque errar para baixo deixa o app lento, não morto. Aceita '2G', '70%' (do total físico) ou 'none' para não ter."
        },
        "memoryMax": {
          "type": "string",
          "pattern": "^(none|[0-9]+(\\.[0-9]+)?(K|M|G|T|%)?)$",
          "description": "Teto duro (systemd MemoryMax): passar daqui é OOM kill do app — e é justamente o que impede que o OOM killer escolha a sessão inteira do usuário no lugar. Aceita '4G', '85%' ou 'none'."
        },
        "cpuQuota": {
          "type": "string",
          "pattern": "^(none|[0-9]+%)$",
          "description": "Teto de CPU (systemd CPUQuota), '100%' = um núcleo inteiro. Sem padrão do ambiente de propósito: CPU disputada deixa a sessão LENTA, e o escalonador já reparte; memória esgotada a derruba. Declare quando o app tiver de conviver, não para 'ser bem-comportado'."
        },
        "tasksMax": {
          "type": "string",
          "pattern": "^(none|[0-9]+%?)$",
          "description": "Teto de processos E THREADS do app (systemd TasksMax). Conta thread, então um app com pool de workers precisa de folga — e é por isso que o padrão do ambiente é generoso. Aceita '512', '25%' (de kernel.pid_max) ou 'none'."
        }
      }
    },
    "recursos": {
      "type": "object",
      "additionalProperties": false,
      "description": "O que o app pede ao sistema além dos tetos de `resources`, e que o sistema arbitra ao subir o app. O app declara o que precisa e pergunta o que recebeu por `vssh.gpu` (`concedida()` no backend, `estado()` na janela); quem decide, com o que o servidor tem, é o vssh-app-run. O app não escolhe como o recurso chega, e é isso que o poupa de conhecer `CUDA_VISIBLE_DEVICES` e o `limits.json`.",
      "properties": {
        "gpu": {
          "type": "object",
          "additionalProperties": false,
          "required": ["modo"],
          "description": "A GPU do servidor. Declarar faz duas coisas. O ambiente consulta o kernel (/sys/class/drm e /dev/dri) e registra o que há: fabricante (NVIDIA, AMD, Intel, virtio ou uma placa virtual), driver, se é virtual, e se o processo do usuário abre o render node; um usuário fora do grupo `render` é o modo de falha mais comum, mais que a ausência de placa. E o app deixa de receber `CUDA_VISIBLE_DEVICES=\"\"`, que todo app sem esta declaração recebe para o runtime CUDA não enumerar dispositivo nenhum; é o que deixa um app de inferência conviver com os vizinhos. A arbitragem é por convenção: a variável não fecha /dev/dri, e a fronteira de verdade pediria controle de dispositivo no cgroup (eBPF, root). O que o sistema decidiu chega ao app por `vssh.gpu.concedida()` como `{ concedida, dispositivos, motivo }`, e o motivo de uma negativa fica no run.log e no gerenciador de tarefas.",
          "properties": {
            "modo": {
              "enum": ["opcional", "necessaria"],
              "description": "`opcional`: o app sobe com ou sem GPU e pergunta o que recebeu; sem placa ele costuma ficar mais lento, e quem sabe se dá para seguir em CPU é o app. `necessaria`: sem GPU utilizável o app não sobe; o run.log nomeia o motivo, e o supervisor o mostra como falha de start, sem laço de reinício, até alguém pedir a subida de novo. Declare `necessaria` só para um app que não tem caminho em CPU: um servidor sem placa, ou um usuário fora do grupo `render`, deixa esse app parado em vez de lento."
            }
          }
        },
        "fila": {
          "type": "boolean",
          "default": false,
          "description": "A fila de processamento: o app delega trabalho pesado (um container com imagem, comando e arquivos) ao cluster, num Job do Kubernetes no sítio em que a placa pedida cabe, em vez de rodá-lo na GPU da estação. A imagem precisa de bash e de curl ou python3, que levam as entradas e as saídas, e o comando vai inteiro, porque o ENTRYPOINT dela não roda. Declarar faz o portal escrever `VSSH_PORTAL_URL` e `VSSH_PORTAL_TOKEN` no ambiente do app a cada subida; com eles o backend chama `vssh.fila` (`submeter`, `acompanhar`, `baixar`, `cancelar`). O token é a identidade do app diante do portal, alcança `/api/fila/*` (e `/api/salas/*`, se o app também declarar `recursos.salas`), e morre com o app (`stop` o revoga). Sem esta declaração o app não recebe o par, e `vssh.fila.disponivel()` diz por quê. Um servidor com a fila desligada responde o mesmo: o app pergunta antes de prometer."
        },
        "salas": {
          "type": "boolean",
          "default": false,
          "description": "As salas de edição: várias pessoas no mesmo documento Yjs, com o portal de relé. A página do app abre uma sala pelo bilhete que a sessão da pessoa pede, e isso não depende desta declaração. Declarar faz o portal escrever `VSSH_PORTAL_URL` e `VSSH_PORTAL_TOKEN` no ambiente do app, com o escopo das salas: o backend pede o bilhete como a pessoa dona da credencial e entra nas salas DESTE app, para gravar no arquivo dela o que as outras pessoas escreveram. A credencial não abre a sala de outro app."
        },
        "metricas": {
          "type": "boolean",
          "default": false,
          "description": "As métricas e os erros do backend, publicados no ambiente. Declarar faz o portal escrever `VSSH_PORTAL_URL` e `VSSH_PORTAL_TOKEN` no ambiente do app, com o escopo das métricas, e com eles o backend chama `vssh.metricas`: `contar` soma a um contador (uma transcrição, os segundos de áudio processados), `duracao` registra quanto algo levou, e `relatar_erro` (`relatarErro` em Node) manda uma exceção ao log do ambiente, ao lado dos erros do portal. O que o app publica sai no `/metrics` do portal como `vssh_app_events_total` e `vssh_app_event_duration_seconds`, com o id do app, o nome do evento e até cinco rótulos que o app escolhe. Um rótulo diz de que tipo é o evento (o motor, a língua, onde rodou), e nunca quem o causou: o portal recusa o que passa de 300 séries por app. Sem esta declaração o app não recebe o par, e `vssh.metricas.disponivel()` diz por quê; publicar sem o par não faz nada."
        }
      }
    },
    "gpu": {
      "type": "boolean",
      "default": false,
      "description": "A grafia anterior de `recursos.gpu.modo: \"opcional\"`, lida pelos mesmos consumidores (o vssh-app-run e a projeção de GET /api/apps) e com o mesmo efeito. Escreva a nova. Esta fica aceita enquanto houver app publicado que a declare, e `recursos.gpu.modo` manda quando os dois aparecem no mesmo manifesto."
    },
    "secrets": {
      "type": "array",
      "description": "Credenciais que o app precisa receber pelo ambiente (chave de API, senha de banco, token de S3). Declarar é o ponto: sem isto, cada app inventa o próprio jeito — normalmente um arquivo em texto plano no VSSH_APP_DATA_DIR — e o usuário não tem onde colocar a credencial. O valor NUNCA vem do manifesto: quem o guarda é o usuário, pelo shell, e ele mora em ~/.vssh-apps/<id>/secrets.json (modo 0600) no servidor do próprio usuário. O portal escreve e esquece — não há coluna de segredo no portal. O app lê como variável de ambiente comum.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "pattern": "^[A-Z][A-Z0-9_]{0,63}$",
            "description": "Nome da variável de ambiente. Maiúsculas por convenção e por segurança: este nome vira um `export <nome>=` avaliado pelo shell no servidor."
          },
          "description": {
            "type": "string",
            "description": "O que é, e onde consegui-la. Aparece na tela onde o usuário digita — e é a diferença entre um campo que ele preenche e um campo que ele ignora."
          },
          "required": {
            "type": "boolean",
            "description": "Sem este segredo o app não funciona. Informativo: o ambiente não recusa subir por falta dele, porque quem sabe degradar é o app.",
            "default": false
          }
        }
      }
    },
    "icon": {
      "type": "string",
      "description": "Caminho relativo à raiz do pacote (.svg/.png/.jpg)."
    },
    "category": {
      "type": "string",
      "description": "A seção do Start Menu/Launchpad onde este app aparece — use o nome de categoria do menu freedesktop ('Development', 'Office', 'Graphics', 'Utility', 'System'…) para cair junto do que faz a mesma coisa. Quem não declara cai em 'Other'. O agrupamento é por este campo e nada mais: não há seção de 'apps integrados' que recolha vssh-app por ser vssh-app, porque isso separa por PROCEDÊNCIA o que o usuário procura por FUNÇÃO."
    },
    "description": {
      "type": "string"
    },
    "developer": {
      "type": "object",
      "description": "Quem fez o software que este app entrega. Para um app escrito para o VSSH é quem o escreveu; para um port (um Recoll, um Xpra) é o projeto original, e é esse nome que a loja mostra em 'por'. O campo é uma declaração do manifesto, e a loja o mostra como tal: quem de fato publicou cada versão é registro do repositório, e sai de lá.",
      "additionalProperties": false,
      "required": [
        "name"
      ],
      "properties": {
        "name": {
          "type": "string",
          "minLength": 1,
          "description": "Como a loja escreve o nome: uma pessoa, um projeto ou uma organização."
        },
        "url": {
          "type": "string",
          "format": "uri",
          "description": "Onde encontrar quem assina: o site do projeto, o perfil ou o repositório."
        }
      }
    },
    "publisher": {
      "type": "object",
      "description": "Quem empacotou o software para o VSSH, quando difere de `developer`: a pessoa ou o grupo que fez o port e responde por ele. Sem este campo a loja entende que quem desenvolveu também empacotou. Com ele, a loja escreve 'portado por' ao lado de 'por'.",
      "additionalProperties": false,
      "required": [
        "name"
      ],
      "properties": {
        "name": {
          "type": "string",
          "minLength": 1,
          "description": "Como a loja escreve o nome: uma pessoa, um projeto ou uma organização."
        },
        "url": {
          "type": "string",
          "format": "uri",
          "description": "Onde encontrar quem assina: o site do projeto, o perfil ou o repositório."
        }
      }
    },
    "homepage": {
      "type": "string",
      "format": "uri",
      "description": "A página do app: documentação, site do projeto ou o repositório. A loja a mostra na ficha do app."
    },
    "license": {
      "type": "string",
      "minLength": 1,
      "description": "A licença do software, como identificador SPDX ('MIT', 'GPL-3.0-or-later', 'Apache-2.0'). Texto livre também passa; o identificador é o que a loja consegue ligar ao texto da licença."
    },
    "handles": {
      "type": [
        "string",
        "null"
      ],
      "enum": [
        "terminal",
        "editor",
        "fileBrowser",
        "ide",
        "vscode",
        "browser",
        null
      ],
      "description": "Registra o app como substituto de um dos 5 launchers embutidos, pelo PAPEL que ele ocupa. O usuário ainda precisa escolher em Configurações → Ambiente → Abrir com. `vscode` é o nome antigo de `ide` e continua aceito (um manifesto já publicado não deixa de instalar por causa de uma palavra que trocamos), mas era o único valor do enum que nomeava um produto em vez de um papel — declare `ide`."
    },
    "opens": {
      "type": "object",
      "description": "Tipos de arquivo que o app sabe abrir — coloca o app no menu 'Abrir com' e o torna elegível a padrão por tipo. Generaliza `handles`, que só cobre os 5 launchers.",
      "additionalProperties": false,
      "properties": {
        "extensions": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^\\.?[A-Za-z0-9][A-Za-z0-9+._-]*$"
          },
          "description": "Com ou sem ponto; normalizado pelo portal."
        },
        "mimeTypes": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^[a-zA-Z0-9!#$&^_.+-]+/[a-zA-Z0-9!#$&^_.+-]+$"
          }
        },
        "default": {
          "type": "boolean",
          "description": "Pede o duplo-clique nas extensões de `extensions` quando outro app instalado também as declara. É para quem mostra o arquivo (a Lupa com foto e PDF, o Palco com vídeo), e não para quem o importa ou organiza (um gerenciador de referências que também lê PDF). Um app sozinho numa extensão é o padrão dela com ou sem este campo; com dois pedindo a mesma, ninguém é eleito e a pessoa escolhe em Configurações > Tipos de arquivo. A escolha gravada pela pessoa vence sempre, e o campo não vale para tipo com destino próprio no ambiente (código, texto, comprimido, HTML)."
        },
        "proprios": {
          "type": "array",
          "maxItems": 32,
          "description": "Os tipos de arquivo que o app cria, desenhados com a marca dele: a folha do ícone de arquivo leva o ícone do app no meio e o selo na cor dele, e a coluna Tipo do gerenciador de arquivos mostra o nome. Cada extensão daqui também entra em `extensions`. Uma extensão que o ambiente já desenha (imagem, vídeo, áudio, comprimido, código, texto, documento de escritório e PDF) continua com o ícone do ambiente. Com dois apps declarando a mesma extensão, vale a do app que abre o tipo no duplo-clique.",
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "extensao",
              "nome",
              "cor"
            ],
            "properties": {
              "extensao": {
                "type": "string",
                "pattern": "^\\.?[a-z0-9][a-z0-9+_-]{0,15}$",
                "description": "A extensão, em minúsculas, com ou sem ponto: `prelo`."
              },
              "nome": {
                "type": "string",
                "minLength": 1,
                "maxLength": 60,
                "description": "Como a coluna Tipo chama o arquivo: `Documento do Prelo`."
              },
              "cor": {
                "type": "string",
                "pattern": "^#[0-9a-fA-F]{6}$",
                "description": "A cor do selo, em `#rrggbb`. O texto do selo é branco, e o `vssh-app-publish` e a instalação recusam a cor em que o branco fica abaixo de 4,5:1."
              },
              "icone": {
                "type": "string",
                "pattern": "^[A-Za-z0-9][A-Za-z0-9/._-]*\\.(svg|png|jpe?g)$",
                "description": "O ícone que vai no meio da folha, como caminho relativo à raiz do pacote. Sem ele, vai o `icon` do app."
              },
              "selo": {
                "type": "string",
                "pattern": "^[A-Z0-9]{1,5}$",
                "description": "O texto curto do selo, até 5 letras maiúsculas ou números: `PRELO`. Sem ele, a folha fica sem selo, e o ícone do app identifica o arquivo sozinho."
              }
            }
          }
        },
        "urls": {
          "type": "array",
          "items": {
            "type": "string",
            "pattern": "^(?:\\*\\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?\\.)+[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?$"
          },
          "description": "HOSTS cujos links este app sabe abrir — é o que faz um link do YouTube cair no seu player em vez de virar aba do navegador. Só o host, sem esquema, sem porta e sem caminho: discriminar por porta ou por rota seria regra de roteamento, e quem roteia dentro do app é o app. `youtube.com` casa o host EXATO; `*.youtube.com` casa também os subdomínios (`m.`, `www.`, `music.`) — a mesma semântica dos padrões de userscript, e ⚠ NÃO um sufixo: `evilyoutube.com` não casa com `youtube.com`, embora termine com ele. O padrão recusa, de propósito: `*` e `*.com` (curinga sobre sufixo público — um app tomaria a internet inteira), IP literal (endereço não é identidade de site) e nome de rótulo único, do qual `localhost` é o caso que importa — aqui ele é o loopback DO SERVIDOR, e reivindicá-lo sequestraria todo servidor de desenvolvimento do ambiente. ⚠ O que o padrão NÃO consegue recusar é curinga sobre sufixo público de mais de um rótulo (`*.co.uk`), que exige a Public Suffix List; essa recusa é do portal. Declarar um host não elege ninguém: com dois apps declarando o mesmo host, o usuário decide em Configurações → Sites e, até lá, o link segue para o navegador."
        }
      }
    },
    "backend": {
      "type": "object",
      "required": [
        "runtime",
        "entrypoint"
      ],
      "additionalProperties": false,
      "properties": {
        "runtime": {
          "enum": [
            "python3",
            "node",
            "binary"
          ],
          "description": "Prefira um runtime que o próprio mecanismo já exija (python3): um nvm/pyenv pessoal não aparece no PATH de um exec não-interativo por SSH."
        },
        "entrypoint": {
          "type": "string",
          "description": "Relativo à raiz do pacote. Deve bindar no socket unix de $VSSH_APP_SOCKET — o único endereço que existe, e é o que o `transport` logo abaixo diz, com um enum de um valor só. Com as libs do toolkit isso é uma linha: escutar(server) no Node, criar_servidor() no Python. Um app que binde uma porta em 127.0.0.1 não é proxiado por ninguém: healthcheck 000, janela em branco, e nada no log dizendo por quê."
        },
        "transport": {
          "enum": [
            "socket"
          ],
          "default": "socket",
          "description": "Onde o backend escuta. Hoje há um valor só: 'socket' — um socket unix em $VSSH_APP_SOCKET, dentro de ~/.vssh-apps/<id>/, que já é 0700. A permissão de arquivo faz o que a conferência de X-Vssh-App-Token só promete, e o app deixa de ter porta alcançável por qualquer outra conta Linux da máquina (medido: 14 de 23 portas de app responderam a um GET sem token, 12 delas de contas alheias). ⚠ 'tcp' não existe mais, e o campo continua aqui só para que um manifesto que ainda o declare receba um erro de validação que NOMEIA o problema, em vez de instalar e nunca responder. Nenhum app fala TCP, e a própria lib do toolkit não sabe bindá-lo: escutar() só abre socket, e trata um VSSH_APP_PORT sozinho como servidor desatualizado."
        },
        "aoFechar": {
          "type": "string",
          "pattern": "^(encerrar|manter|ocioso:[1-9][0-9]{0,3}m)$",
          "default": "encerrar",
          "description": "O que acontece com o backend quando a ULTIMA janela deste app fecha, ou quando ninguem mais o usa. 'encerrar' (padrao): o ambiente chama o stop dois minutos depois, e uma janela do app aberta nesse intervalo o cancela e reaproveita o backend de pe. 'manter': o backend fica, porque quem o mantem vivo e o trabalho e nao a janela, como o dono de sessao de terceiro. 'ocioso:<N>m' (de 1 a 9999 minutos): o backend fica enquanto alguem o usa, e o supervisor do servidor o encerra depois de N minutos sem conexao nenhuma no socket dele, venha ela do portal ou do cliente de desktop; e o valor de um motor sob demanda, que nao tem janela. O proxy do portal segura uma conexao parada por ate 10 minutos, e ela conta como uso. O padrao e 'encerrar' porque um padrao que vaza memoria tem de ser o que se escolhe: sem contrato, um app aberto uma vez seguiria ocupando RAM em toda sessao daquela conta, sem janela na tela. kind:'service' resolve 'encerrar' e 'manter' para 'manter', porque um daemon nao morre com uma janela, e respeita 'ocioso:<N>m'."
        },
        "installCommand": {
          "type": "string",
          "description": "Roda DUAS vezes: uma como root no install, outra por usuário no primeiro run. Escreva de forma idempotente."
        },
        "healthcheckPath": {
          "type": "string",
          "default": "/",
          "description": "Pollado direto no endereço do app pelo lifecycle (socket unix, via `curl --unix-socket`), até 15x/1s. A sondagem vai COM o header X-Vssh-App-Token, então gatear esta rota é permitido — e isentá-la do seu gate seria abrir uma rota sem motivo. NÃO contam como pronto: 000, 5xx e 401/403. Um 404 CONTA — o servidor respondeu —, então confira o caminho."
        }
      }
    },
    "window": {
      "type": "object",
      "description": "A janela em que o app abre, no vocabulário das janelas do próprio ambiente. Ignorado por type:'engine'. O portal repassa este objeto inteiro ao cliente (`window: m.window || {}` em routes/apps.ts), e o shell lê só os campos abaixo, então uma chave a mais viaja o caminho todo sem que ninguém a leia. O objeto é fechado por isso: `widht: 900` deixaria a janela no tamanho padrão sem erro em lugar nenhum, e o schema recusa a chave antes de o app publicar.",
      "additionalProperties": false,
      "properties": {
        "title": {
          "type": "string"
        },
        "width": {
          "type": "integer",
          "minimum": 200,
          "maximum": 10000
        },
        "height": {
          "type": "integer",
          "minimum": 150,
          "maximum": 10000
        },
        "richChrome": {
          "type": "boolean",
          "description": "Tabbar de verdade no cabeçalho + itens de aba no menu de contexto."
        },
        "multiplasJanelas": {
          "type": "boolean",
          "default": false,
          "description": "Cada pedido de abrir cria uma janela NOVA, em vez de focar a que já existe. O padrão (`false`) é o certo para quem tem abas ou lista própria — abrir um segundo `.md` no editor não deve abrir um segundo editor. Declare `true` quando cada coisa aberta é uma sessão inteira e independente: um player, um visualizador de imagem, um terminal. ⚠ **O backend continua sendo UM.** Duas janelas do mesmo app são duas visões do mesmo processo, como duas abas do navegador no mesmo servidor — não há porta, token nem `VSSH_APP_DATA_DIR` separados, e um app que declara isto precisa aguentar dois `open-context` sem que o segundo apague o estado do primeiro. Vale para o \"Abrir com\" do gerenciador de arquivos e para o ícone no menu iniciar e no launchpad; NÃO vale para clique em notificação, que sempre foca a janela que a produziu."
        },
        "cabecalho": {
          "enum": [
            "ambiente",
            "app"
          ],
          "default": "ambiente",
          "description": "Quem desenha a barra de título. `ambiente` (padrão) é o cabeçalho do desktop. `app` é para quem JÁ TEM uma barra de título própria e boa — o editor, cuja barra é a do VS Code: o shell não monta a dele, e o app põe os botões de janela na sua, chamando `vssh.window.minimize/maximize/close`. Moldura, sombra e alças de resize continuam sendo do ambiente. Quem escolhe `app` fica responsável por três gestos que o cabeçalho padrão dava de graça: arrastar (`vssh.window.arrastar`), duplo-clique (`alternarMaximizado`) e menu de contexto (`menuDoCabecalho`) — um app que declara isto e não os liga entrega uma janela que não se move."
        },
        "minimos": {
          "type": "object",
          "additionalProperties": false,
          "description": "O piso do redimensionamento pelas alças, para o app que precisa de mais que os 400×300 que o ambiente impõe a toda janela. Um lado só também vale: `{ \"largura\": 640 }` deixa a altura no piso do ambiente. Maximizar e encaixar continuam alcançando qualquer tamanho, e o piso vale para toda janela do app, inclusive a que ele abre pelo verbo `janela.abrir`. O piso é da tela larga: abaixo de 600 px de tela o ambiente põe a janela na área útil inteira e o piso cede, então o app precisa caber em 320 px de largura, que é o que a moldura do Tuff (`.tuff-app`) dá sem CSS próprio.",
          "properties": {
            "largura": {
              "type": "integer",
              "minimum": 400,
              "maximum": 10000
            },
            "altura": {
              "type": "integer",
              "minimum": 300,
              "maximum": 10000
            }
          }
        },
        "posicao": {
          "enum": [
            "cascata",
            "centrada"
          ],
          "default": "cascata",
          "description": "Onde a janela nasce. `cascata` (padrão) é a janela de trabalho: cada uma abre um pouco abaixo e à direita da anterior, a partir do canto do display em que está o ponteiro. `centrada` é o diálogo e o painel: no meio desse display. Uma janela restaurada de uma sessão anterior volta onde estava, e este campo não a alcança."
        },
        "cancelarArraste": {
          "type": "array",
          "items": {
            "type": "string",
            "minLength": 1
          },
          "description": "Seletores CSS que não iniciam o arraste da janela, somados à lista do ambiente (botões, campos de formulário). Só alcançam o que o shell desenha em volta do iframe, como a barra de abas de quem tem `richChrome`: o ponteiro dentro do documento do app nunca chega ao shell. O shell descarta um seletor que o navegador não aceita."
        },
        "icone": {
          "type": "object",
          "additionalProperties": false,
          "description": "O ícone da janela (barra de título, barra de tarefas, Alt+Tab), quando ele não é o `icon` do app. `glifo` é um desenho do próprio ambiente, pintado na cor de destaque que a pessoa escolheu, para o app parecer nativo; `url` é um caminho dentro do app, relativo à raiz dele, que o backend do app serve. Declare um dos dois; `glifo` vence quando há os dois, e o shell descarta um caminho que sai do app.",
          "properties": {
            "glifo": {
              "type": "string",
              "enum": [
                "pasta",
                "globo"
              ]
            },
            "url": {
              "type": "string",
              "minLength": 1
            }
          }
        }
      }
    }
  }
}
