Guias

Avisos e atividades

Ao terminar este guia você sabe escolher entre uma notificação, um aviso efêmero e uma atividade, emitir cada um com a janela aberta pelo espaço vssh.avisos, e emitir os mesmos três de um backend sem janela, por arquivo. A escolha decide se o sino do ambiente continua querendo dizer alguma coisa.

Os nomes de verbo deste guia seguem a nota sobre os nomes; o shim de hoje os expõe como vssh.notify, vssh.toast e vssh.live.

Três superfícies, três tempos de vida

o que évivedeixa rastro
notificaçãoum fato que aconteceuaté ser lida; retenção por diassim, no histórico do sino
aviso efêmerouma frase que se lê e se esquecesegundosnão
atividadeuma condição que é verdade agoraenquanto valenão

A regra que decide, e que vale para todo ponto de chamada novo: um aviso é para o que não deixa rastro na tela. Se a pessoa vê o resultado acontecer (o item aparecendo na lista, o nome mudando), não mande nada. Se ela vai precisar reencontrar o que aconteceu (um caminho, um id, um erro), é notificação. Se ainda não terminou, é atividade.

O custo de escolher errado não cai no seu app; cai na atenção de quem usa o ambiente inteiro. Um sino cheio de "arquivo salvo" ensina a ser ignorado, e o badge deixa de informar.

Notificação

vssh.avisos.notificar('Índice reconstruído', { titulo: 'Busca', nivel: 'success' });

nivel é o tom, a cor da barrinha: info, success, warning, error. prioridade é outra coisa: quanto isso interrompe.

prioridadebadgeaviso na telanotificação do sistema operacional
baixasimnãonão
normal (padrão)sim4 ssó com a aba oculta
altasimnão some sozinhosó com a aba oculta

Não há nível acima de alta para um app: bloquear a tela de quem está trabalhando é poder do ambiente, e o shell rebaixa para alta o que vem de fora. Para uma pergunta que precisa de resposta, vssh.dialogos abre uma pergunta que a pessoa escolheu abrir.

chave é a identidade: uma notificação com a mesma chave substitui a anterior no lugar. É o que faz "3 de 5 baixados" ser uma linha, e não cinco:

vssh.avisos.notificar(`${feitos} de ${total} baixados`, { chave: 'downloads', prioridade: 'baixa' });

O clique numa notificação abre o seu app. abrir diz onde: um caminho dentro dele, que o ambiente cola na URL da janela nova ou entrega à janela aberta no evento abertura, como a jump list faz (ver Uma janela). Sem ele, o clique abre o app onde ele estiver.

vssh.avisos.notificar('Transcrição pronta.', { titulo: 'entrevista-03.m4a', nivel: 'success',
  abrir: `?documento=${encodeURIComponent(caminho)}` });

Uma rota que sai do app (um esquema, um caminho absoluto, um ..) é recusada, e o clique volta a abrir o app sem rota.

Ver é ler: o ponteiro parado por 600 ms sobre o aviso na tela marca a notificação como lida, e o clique também. O que não marca é o aviso que some sozinho no tempo, porque esse é o caso inteiro que o histórico existe para atender: passou na tela enquanto ninguém olhava.

Se a pessoa estiver em "não perturbe", ou tiver silenciado o seu app pelo botão direito numa notificação dele, a tela não é interrompida; a entrada fica no histórico do mesmo jeito.

Uma notificação que pede resposta

vssh.avisos.notificar('Backup falhou: disco cheio', {
  titulo: 'Backup',
  nivel: 'error',
  prioridade: 'alta',
  acoes: [
    { id: 'retry',  rotulo: 'Tentar de novo' },
    { id: 'ignore', rotulo: 'Ignorar' },
  ],
});

vssh.avisos.ao('acao', ({ notificacao, acao }) => { if (acao === 'retry') tentarDeNovo(); });

No máximo três ações, cada uma { id, rotulo }, e são dados: nunca uma função, nunca um caminho por botão. Os botões aparecem no aviso na tela e na linha do painel, porque o aviso some, e uma notificação que só pudesse ser respondida enquanto ele estivesse na tela expiraria sem avisar.

Aviso efêmero

vssh.avisos.avisar('Copiado');
vssh.avisos.avisar('Enviando 3 de 5…', { chave: 'envio' });   // a mesma chave reescreve no lugar

Nunca entra no histórico. Teto de 4 na tela, com "+N avisos" para o que a rajada engoliu; quem está sendo lido (ponteiro em cima) não é derrubado. Se a pessoa vai querer reencontrar o que você disse, não é isto.

Atividade

vssh.avisos.atividade('sync', {
  titulo: 'Sincronizando',
  texto: arquivoAtual,
  formato: 'progresso',                     // 'simples' | 'progresso' | 'midia'
  progresso: { feito, total },              // ou { indeterminado: true }
  acoes: [{ id: 'pausar', rotulo: 'Pausar' }],
});

// terminou, e o fim não interessa a ninguém:
vssh.avisos.encerrar('sync');

// terminou, e o desfecho vale um registro:
vssh.avisos.encerrar('sync', { registrar: { titulo: 'Pronto', texto: `${total} arquivos` } });

A atividade aparece como ícone com porcentagem na bandeja e como linha com barra na seção "Agora" do painel do sino, e some quando você a encerra, sem rastro, a menos que peça registrar. Nada disso toca o localStorage: uma atividade que sobrevivesse ao processo que a declarou estaria mentindo sobre o estado do ambiente. Encerre sempre.

A bandeja

Um ícone ao lado do relógio, com tooltip, badge e menu, para o app que continua fazendo algo com a janela minimizada ou fechada. Um item por app, atualizável: chamar de novo troca ícone, tooltip, badge e menu sem o ícone mudar de lugar. O tooltip diz como o app está, porque é isso que quem olha a bandeja quer saber. Devolve false em vez de lançar quando não há bandeja do outro lado. O verbo vive em vssh.avisos; a forma dele está na referência.

Sem janela: o backend fala por arquivo

vssh.avisos é do frontend e precisa de uma janela viva para atravessar a ponte. Um daemon que termina um backup às 3h não tem janela, e é ele que mais precisa avisar. Para ele, o modelo se inverte: estado por arquivo, ação por HTTP. O portal lê os três arquivos no mesmo tick da bandeja, um comando por servidor cobrindo todos os usuários, e só enquanto alguém tiver o desktop aberto.

~/.vssh-apps/<id>/tray.json               estado: vale o conteúdo atual; sumiu o arquivo, sumiu o ícone
~/.vssh-notifications/journal.ndjson      histórico: append-only, uma linha por notificação
~/.vssh-notifications/live/<chave>.json   a atividade acontecendo agora

O módulo avisos do runtime escreve os três, nas duas linguagens:

from vssh import avisos

avisos.manter_atividades_vivas(); avisos.limpar_atividades_ao_sair(); avisos.limpar_bandeja_ao_sair()

avisos.notificar('Backup concluído: 4,2 GB em 12 min', titulo='Backup', nivel='success')
avisos.notificar('Disco quase cheio', chave=f'disco-{hoje}')    # avisar uma vez só, mesmo rodando de hora em hora
avisos.notificar('Transcrição pronta.', abrir='?documento=entrevista-03.transcricao')   # o clique leva lá

avisos.atividade('sync', {'titulo': 'Sincronizando', 'formato': 'progresso',
                          'progresso': {'feito': 3, 'total': 12}})
avisos.limpar_atividade('sync', registrar={'titulo': 'Sincronização concluída', 'texto': '12 arquivos'})
const { avisos } = require('vssh');

avisos.manterAtividadesVivas(); avisos.limparAtividadesAoSair(); avisos.limparBandejaAoSair();

avisos.notificar('Backup concluído: 4,2 GB em 12 min', { titulo: 'Backup', nivel: 'success' });
avisos.atividade('sync', { titulo: 'Sincronizando', formato: 'progresso', progresso: { feito: 3, total: 12 } });
avisos.limparAtividade('sync', { registrar: { titulo: 'Sincronização concluída', texto: '12 arquivos' } });

Três coisas que a lib faz por você, e que valem saber para quem escreve o formato cru:

  • o id da linha do journal é a chave de deduplicação de ponta a ponta, e errar é silencioso nos dois sentidos: id que se repete entre eventos diferentes faz o segundo nunca aparecer; id que muda para o mesmo evento faz a mesma coisa avisar várias vezes. Nunca use um contador do processo, que reinicia junto. at (epoch ms) é a hora do evento; sem ele, a hora exibida é a da entrega, que para algo que aconteceu com o desktop fechado é a hora errada;
  • o at do live/<chave>.json precisa ser renovado. Um arquivo chamado live sobrevive a um kill -9, e o portal descarta o que passa de 60 s sem renovar; manter_atividades_vivas e manterAtividadesVivas renovam sozinhos enquanto a atividade fica parada esperando rede. O portal lê no máximo 32 arquivos de live/ por usuário; quem precisa de mais quer uma atividade com contador;
  • o clique na bandeja e a ação de uma notificação chegam ao backend como POST no caminho que o arquivo declarou (onClick: { path: '/tray' }, onAction: { path: 'api/notificacao' }), pela mesma rota autenticada que o app já usa, com o X-Vssh-App-Token injetado. path é relativo ao app, sem esquema, sem .., sem //. Se o app tiver uma janela aberta, ela ganha: a resposta vai por postMessage e o POST não acontece, porque entregar pelos dois lados faria "tentar de novo" clicado uma vez virar dois backups.

Em shell, uma notificação é uma linha:

printf '%s\n' "{\"id\":\"backup-$(date +%F)\",\"appId\":\"meu-backup\",\"body\":\"Backup concluído\",\"level\":\"success\",\"at\":$(date +%s000)}" \
  >> ~/.vssh-notifications/journal.ndjson

Escreva com >>, nunca com >, e rotacione você mesmo se o app for tagarela: o portal lê só a janela do fim do arquivo (as últimas ~50 linhas), então um arquivo grande não o atrapalha, mas ocupa o disco da pessoa para sempre. A latência é de segundos.

Janela ganha do arquivo: uma cópia pode ter atividade declarada no cliente e um live/ do lado de lá com a mesma chave, e o coletor não sobrescreve enquanto a do cliente durar. Sem isso o ícone piscaria de 5 em 5 segundos sem explicação.

Notificação do sistema operacional

Com a aba oculta e a permissão concedida, uma notificação normal ou alta também sai como notificação do sistema operacional. É alcance a mais, e o app nunca conta com ela. A pessoa concede no botão "Avisar fora da aba", dentro do painel de notificações.