Arquivos com consentimento
Ao terminar este guia o seu app lê e grava na home da pessoa pela File System Access API do W3C, reabre sem seletor o que ela já concedeu, sabe que um arquivo mudou por fora, abre um arquivo no visualizador certo do ambiente, e recebe e produz arquivos arrastados. O modelo por trás disso está em consentimento.
Os nomes de verbo deste guia seguem a nota sobre os nomes.
A maior parte do código aqui é a API padrão do navegador, que não muda de nome; o que é do
ambiente vive em vssh.arquivos, e o shim de hoje o expõe como vssh.fs.*, vssh.pickFile e
afins.
O que carregar
Dois scripts, servidos pelo seu backend e injetados pelo static-spa (os templates já vêm com os
dois): o shim, que publica o objeto vssh, e o polyfill de File System Access, que depende dele.
A ordem em SHIMS já é a certa. Sem o polyfill, showDirectoryPicker não existe no iframe.
Escolher, ler, gravar
Para trabalhar com arquivos da pessoa, use a API padrão. Não monte um backend de filesystem: o app não precisa de nenhum, e um web app que já usa File System Access (Logseq, Excalidraw, tldraw, editores em geral) roda sem fork.
const dir = await showDirectoryPicker(); // o seletor é o gerenciador de arquivos do ambiente
for await (const [nome, handle] of dir.entries()) {
if (handle.kind !== 'file') continue;
const file = await handle.getFile(); // preguiçoso: não baixa nada ainda
if (!nome.endsWith('.md')) continue; // filtre antes de ler
const texto = await file.text(); // só agora busca o conteúdo
}
const w = await (await dir.getFileHandle('nota.md', { create: true })).createWritable();
await w.write('# título\n');
await w.close();
Escolher é consentir. A pasta escolhida, e o que há dentro dela, passa a ser alcançável pelo app; o resto da home, não. Não há segunda confirmação.
Os seletores também existem fora da API padrão, quando você quer só o caminho:
const pasta = await vssh.arquivos.escolherPasta({ titulo: 'Escolher grafo' });
const arquivo = await vssh.arquivos.escolherArquivo({ filtro: '*.md' });
const destino = await vssh.arquivos.escolherDestino({ nome: 'export.pdf' });
Devolvem o caminho absoluto no servidor, ou null se a pessoa cancelou, e concedem do mesmo jeito.
Com o caminho na mão, vssh.arquivos.ler(caminho) e vssh.arquivos.escrever(caminho, texto)
passam pelo mesmo portão.
O File é preguiçoso
getFile() devolve um File que só busca o conteúdo quando alguém pede. É o que torna viável
abrir um diretório grande, porque o padrão dos apps é pedir o File de todo arquivo antes de
filtrar. file.slice(a, b) lê só a faixa pedida, por Range HTTP, e é o que faz um leitor por
blocos (Parquet, HDF5, Zarr, DICOM) rodar sem baixar o arquivo.
Dois caminhos leem os bytes de forma síncrona e não têm onde encaixar a busca: new Blob([file]) e
FormData.append(nome, file). Eles produzem 0 bytes, com aviso no console, até o arquivo ter sido
lido uma vez. Materialize antes:
await file.arrayBuffer(); // ou .text(), ou qualquer leitura
new Blob([file]); // agora carrega o conteúdo
Reabrir o que já foi concedido
O grant viaja com a pessoa; o handle fica no IndexedDB do navegador, que não viaja. Num computador novo o app acorda com a permissão concedida e sem handle nenhum. Trate essa terceira resposta no boot, antes de mostrar "abrir pasta":
const [grafo] = await vssh.arquivos.concedidos(); // handles, sem abrir seletor
if (grafo) await carregar(grafo);
else await carregar(await showDirectoryPicker());
Um stat por caminho decide file ou directory, e o que não existe mais some da lista em vez de
virar handle morto. Isso não contorna o consentimento: a escolha já aconteceu uma vez, e o que
faltava era um objeto para representá-la nesta máquina.
Quando você guarda um handle no IndexedDB, o polyfill o reidrata na leitura; sem isso o app leria de volta um objeto sem métodos e concluiria que a pasta está vazia. E confira a permissão de verdade em vez de presumi-la:
if (await dir.queryPermission() !== 'granted') {
// Chame a partir de um clique: sem gesto da pessoa, devolve 'prompt' sem abrir seletor.
if (await dir.requestPermission() !== 'granted') return;
}
Se a pessoa escolher outra pasta no seletor reaberto, a resposta é 'denied' e o handle antigo
continua fora. 'denied' é um estado normal; a pessoa revoga em Permissões de arquivo, no menu de
contexto da janela.
Existe? Renomear, mover, copiar
if (await vssh.arquivos.existe(destino)) { /* … */ }
await vssh.arquivos.renomear('/casa/proj/rascunho.md', '/casa/proj/final.md'); // renomear é o mover
await vssh.arquivos.copiar('/casa/proj/final.md', '/casa/backup/final.md');
await vssh.arquivos.renomear(a, b, { sobrescrever: true }); // opt-in explícito
Três precisões, cada uma por um modo de falha concreto:
existe()devolvefalsesó quando o arquivo não existe. Falta de permissão, servidor fora e rede piscando lançam. O idiomastat(p).then(() => true).catch(() => false)colapsa três respostas em duas, e o app cria por cima de um arquivo que estava lá, ou conclui que a pasta da pessoa está vazia porque umfetchfalhou. Não escrevaexiste(p).catch(() => false);- origem e destino precisam ambos estar concedidos, e a recusa é
403nomeando o caminho reprovado; - destino existente falha;
{ sobrescrever: true }é a forma de dizer que você quer mesmo. Sobrescrever sem pedir não tem desfazer.
removeEntry('pasta') recusa apagar uma pasta com conteúdo, como manda a especificação
(InvalidModificationError). A API de arquivos do portal por baixo é um rm -rf, e sem essa guarda
a chamada apagaria tudo em silêncio. { recursive: true } é explícito.
Saber que um arquivo mudou por fora
const parar = await vssh.arquivos.vigiar(dir, ({ caminho, encerrado }) => {
if (encerrado) return; // a assinatura acabou; peça de novo se ainda precisar
recarregar(caminho);
});
// …quando não precisar mais:
parar();
Pega edição por SSH, git pull, upload pelo gerenciador de arquivos: qualquer mudança que não
passou pelo app. Cancele quando parar de precisar, porque cada vigia segura um processo vivo no
servidor e há teto por usuário. Fechar a janela cancela tudo. Isso não está no polyfill de
propósito: a File System Access API não tem watch, e pendurar uma extensão nossa num handle daria
a ela cara de padrão.
Abrir no visualizador do ambiente
vssh.arquivos.abrir('/home/ana/relatorio.pdf'); // PDF, vídeo, imagem, texto, office, zip, pasta
const escolhido = await vssh.arquivos.abrirCom('/home/ana/nota.md'); // a pessoa escolhe com quê
Você manda o caminho absoluto e mais nada. O app nunca precisa saber o serverId nem montar URL
de API; quem monta é o shell, que sabe onde está.
Arrastar, nas duas direções
O contrato é um só: o tipo MIME application/x-vssh-files, com os caminhos absolutos separados
por linha. É como o gerenciador de arquivos e a área de trabalho já conversam, e o app entra dos
dois lados sem declarar nada no manifesto.
// receber: o arquivo que a pessoa arrastou do gerenciador para dentro do app
const desligar = vssh.arquivos.ao('soltos', ({ caminhos, x, y, alvo }) => {
for (const p of caminhos) abrirNoEditor(p);
});
// produzir: arrastar de dentro do app para a área de trabalho, o gerenciador ou a lixeira
aba.draggable = true;
aba.addEventListener('dragstart', (e) => vssh.arquivos.arrastar(e.dataTransfer, [caminhoDaAba]));
Três coisas que economizam uma tarde:
- o ouvinte de soltura chama
preventDefaultnodragover. Sem isso o navegador entende que o elemento recusa a soltura: odropnunca acontece, o cursor fica proibido, e não há erro nem log. Se você escrever o ouvinte à mão, é essa a linha que falta; arrastaravisa o shell sozinho, inclusive o fim do gesto. Todo alvo de soltura do ambiente decide se acende olhando um estado que vive no documento do shell, e umdragendnão atravessa documentos;- o caminho tem de ser absoluto. Um relativo é descartado, e a função devolve
false.
Área de transferência
Texto e imagem vão pela API padrão, navigator.clipboard, direto: o iframe é da mesma origem e
recebe a permissão. O que o ambiente acrescenta é o motivo da falha (no-user-activation,
denied, unsupported), porque as três causas chegam com o mesmo NotAllowedError. Arquivos vão
pela ponte, porque o clipboard de arquivos é do gerenciador: vssh.arquivos lê o que foi copiado
lá, escreve para colar lá, e avisa quando muda. Escrever sempre copia; recortar continua sendo do
gerenciador, onde a pessoa vê o que está fazendo.
OPFS é cache
navigator.storage.getDirectory() funciona e é nativo; o polyfill o isola por app, porque todos
os apps são servidos pela mesma origem e sem isso um leria o cache.db do outro. O que ele não
muda: OPFS é por perfil de navegador e a pessoa troca de máquina. Um app que guarda a verdade ali
perde tudo na troca, sem erro nenhum. A verdade vai para o filesystem da pessoa ou para o backend
do app; OPFS é aceleração reconstruível.