No post anterior sobre launchd, o agendamento funcionava por horário: o StartCalendarInterval definia “todo dia às 7h” e o sistema cuidava do resto, incluindo recuperar execuções perdidas quando o Mac estava dormindo. Para tarefas periódicas como enviar um briefing diário ou rodar um script de manutenção, esse modelo resolve perfeitamente — é o equivalente funcional do cron, mas integrado ao ciclo de vida do macOS.

Só que nem toda automação faz sentido atrelada a um relógio. Tem tarefas que só precisam acontecer quando alguma coisa muda. Um backup que roda de hora em hora está desperdiçando 23 execuções por dia se o banco de dados só foi alterado uma vez. Uma conversão de imagens que roda a cada 5 minutos não tem o que converter na grande maioria das vezes, e quando finalmente tem, já se passaram até 5 minutos desde que o arquivo apareceu. O modelo baseado em tempo funciona, mas é um polling disfarçado de agendamento — e polling é quase sempre a solução menos elegante para qualquer problema de sincronização.

O launchd oferece uma alternativa que funciona de forma fundamentalmente diferente: em vez de perguntar “que horas são?”, ele pergunta “algo mudou?”. A chave WatchPaths aceita uma lista de caminhos — arquivos ou diretórios — e dispara o job quando qualquer um deles é modificado. Não é um intervalo. Não é um cron disfarçado. É um gatilho reativo, baseado em eventos do sistema de arquivos, que transforma o launchd numa espécie de inotifywait do Linux, mas declarativo e integrado ao sistema operacional.

Este post explora dois cenários práticos para essa capacidade. O primeiro é um backup automático de um banco SQLite que sincroniza com um servidor remoto sempre que o banco é modificado, com um mecanismo de throttle para não martelar o destino a cada salvamento. O segundo é um conversor de imagens que vigia uma pasta e transforma PNGs e JPGs em formatos otimizados assim que eles aparecem. Os dois scripts ficam para posts dedicados — o script de conversão de imagens já está publicado. Aqui o foco é na mecânica do launchd e na construção dos plists.

De horários para eventos: o outro lado do launchd#

O StartCalendarInterval que apareceu no post anterior e o WatchPaths que vamos usar agora são mutuamente exclusivos no mesmo plist. Cada LaunchAgent tem um único gatilho de ativação: ou ele dispara por horário, ou ele dispara por mudança no sistema de arquivos, ou ele dispara por outra condição como StartInterval (um timer simples em segundos) ou KeepAlive (que mantém o processo rodando continuamente e o reinicia se morrer). Misturar StartCalendarInterval com WatchPaths no mesmo plist não gera erro de sintaxe, mas o comportamento resultante é indefinido e a documentação da Apple não garante qual dos dois terá precedência.

A restrição faz sentido quando você pensa no modelo mental do launchd: cada plist descreve um job, e cada job tem uma razão para existir — uma condição que o traz à vida. Se você precisa de um script que rode às 7h da manhã e quando um arquivo mudar, a solução é criar dois plists apontando para o mesmo script (ou para scripts diferentes que compartilham a mesma lógica). Parece redundante comparado com um timer do systemd que aceita múltiplos OnCalendar e PathChanged na mesma unit, mas na prática a separação mantém os jobs simples e facilita o debug — cada plist faz uma coisa, e quando algo não funciona, o problema está isolado num único arquivo.

WatchPaths: como funciona#

A sintaxe do WatchPaths é um array de strings no plist, onde cada string é o caminho absoluto de um arquivo ou diretório. Quando o launchd detecta uma modificação em qualquer um dos caminhos listados, ele executa o job. A detecção usa o kqueue do kernel — o mesmo mecanismo que o fswatch e o FSEvents usam por baixo dos panos — então não há polling envolvido. O overhead é zero enquanto nada muda, e a reação é praticamente instantânea quando algo muda.

Aqui está a forma mínima de um plist com WatchPaths:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.exemplo.watchpaths</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/janio/bin/meu-script.sh</string>
    </array>
    <key>WatchPaths</key>
    <array>
        <string>/Users/janio/dados/arquivo.db</string>
    </array>
</dict>
</plist>

Toda vez que /Users/janio/dados/arquivo.db for modificado — seja por uma escrita direta, por um mv que substitui o arquivo, ou por qualquer operação que altere seus metadados — o launchd executa /Users/janio/bin/meu-script.sh. Não precisa de loop, não precisa de while true, não precisa de processo residente consumindo memória. O script roda, faz o que precisa fazer, e termina. Na próxima modificação, o launchd o executa de novo.

Um ponto que não é óbvio na documentação: quando o caminho monitorado é um diretório, o launchd dispara quando qualquer arquivo dentro dele é criado, removido ou renomeado, mas não necessariamente quando o conteúdo de um arquivo existente é modificado sem alterar a estrutura do diretório. O comportamento exato depende de como o aplicativo que grava o arquivo implementa a escrita — alguns editores criam um arquivo temporário e fazem um rename atômico (o que altera o diretório), enquanto outros escrevem diretamente no arquivo existente (o que pode não disparar o watch no diretório). Para monitorar o conteúdo de um arquivo específico, a prática mais confiável é apontar o WatchPaths diretamente para o arquivo, não para o diretório que o contém.

Também vale saber que o WatchPaths não é recursivo. Monitorar /Users/janio/dados/ detecta mudanças no nível imediato desse diretório, mas não em subpastas. Se a estrutura de diretórios importa, cada caminho relevante precisa estar listado explicitamente no array. É uma limitação quando comparado com o inotifywait --recursive do Linux, mas na prática a maioria dos casos de uso envolve vigiar um ou dois caminhos específicos, não uma árvore inteira.

Cenário 1: backup reativo de um banco SQLite#

O problema#

Bancos SQLite são arquivos. Essa é simultaneamente a maior qualidade e o maior risco do SQLite: não há servidor intermediário, não há daemon gerenciando conexões, não há replicação embutida. O banco é um arquivo .db no disco, e se esse arquivo se corromper ou o disco falhar, os dados vão junto. Para aplicações pessoais que guardam dados que importam — um gerenciador de tarefas, um leitor de RSS, um inventário doméstico — a ausência de backup automático é uma bomba-relógio silenciosa que só detona no pior momento possível.

A abordagem tradicional seria agendar um backup periódico com StartCalendarInterval — digamos, a cada hora. Funciona, mas tem dois problemas. Primeiro, a maioria das execuções vai copiar um banco que não mudou desde o último backup, desperdiçando tempo e banda com o destino remoto. Segundo, se você fizer uma série de alterações importantes e o laptop morrer antes da próxima hora cheia, as mudanças se perdem. O intervalo fixo cria uma janela de vulnerabilidade proporcional à sua frequência, e diminuir a frequência para um minuto transforma o backup num polling agressivo que consome recursos sem necessidade.

Com WatchPaths, o modelo muda: o backup só roda quando o banco é efetivamente modificado. Sem alteração, nenhum processo é executado. Com alteração, o script dispara em segundos. A janela de vulnerabilidade cai de “até uma hora” para “até alguns segundos”, e o custo em recursos cai para zero nos períodos de inatividade.

A lógica do script#

O script de backup faz quatro coisas, nessa ordem: copiar os arquivos relevantes para um diretório de staging local, forçar um WAL checkpoint no banco SQLite para garantir consistência, sincronizar o staging com o destino remoto via rclone, e registrar o horário da execução para controle de throttle.

O diretório de staging existe para evitar que o rclone leia o banco diretamente enquanto ele está sendo escrito. SQLite no modo WAL (Write-Ahead Logging) mantém um arquivo -wal ao lado do banco principal com transações ainda não consolidadas. Copiar o .db sem antes rodar um PRAGMA wal_checkpoint(TRUNCATE) pode resultar num backup inconsistente — o arquivo principal sem as últimas transações e o WAL ausente ou truncado. O checkpoint força a consolidação do WAL no banco principal antes da cópia, e o staging garante que o rclone trabalhe com um snapshot estático, não com um arquivo que pode mudar durante o upload.

O destino remoto neste caso é um bucket no Backblaze B2, mas a lógica é idêntica para qualquer remote que o rclone suporte — S3, Google Drive, SFTP, um NAS local. O rclone sync cuida de transferir apenas os blocos que mudaram, então mesmo um banco de vários megabytes gera tráfego mínimo quando as alterações são pequenas.

O plist#

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.janio.fuqu-backup</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/janio/bin/fuqu-backup.sh</string>
    </array>
    <key>WatchPaths</key>
    <array>
        <string>/Users/janio/.local/share/fuqu/fuqu.db</string>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/janio/.local/share/fuqu/backup.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/janio/.local/share/fuqu/backup.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

O WatchPaths aponta diretamente para o arquivo fuqu.db, não para o diretório que o contém. Como mencionado na seção anterior, monitorar o diretório poderia não capturar escritas feitas diretamente no arquivo pelo SQLite — especialmente no modo WAL, onde a escrita inicial vai para o -wal e só depois é consolidada no banco principal. Apontar para o .db garante que o launchd dispare quando o próprio banco for modificado, inclusive após um checkpoint automático do SQLite.

O PATH no EnvironmentVariables inclui /opt/homebrew/bin porque é onde o Homebrew instala binários em Macs com Apple Silicon, e o rclone vem de lá. Sem essa variável, o script não encontraria o rclone — o mesmo problema de PATH que apareceu no post anterior com o fuqu telegram.

Throttle: por que não dá para confiar no launchd sozinho#

Existe um detalhe que a documentação da Apple menciona de passagem mas que tem implicações práticas sérias: o launchd tem um throttle interno de 10 segundos entre execuções de um mesmo job. Se o banco for modificado duas vezes em sequência rápida, a segunda execução é atrasada para respeitar esse intervalo mínimo. O valor pode ser ajustado com a chave ThrottleInterval no plist, mas só para cima — não é possível reduzir abaixo de 10 segundos.

Para um backup remoto, 10 segundos é até pouco. Uma aplicação que faz muitas escritas pequenas no SQLite — adicionar uma tarefa, marcar outra como concluída, editar uma nota — pode gerar dezenas de modificações no banco em poucos minutos de uso ativo. Disparar um rclone sync para cada uma delas não faz sentido: cada execução tem o overhead de conectar ao servidor remoto, autenticar, comparar checksums e transferir dados. Num bucket B2, cada operação de escrita é uma transação de API que conta para o limite gratuito mensal.

A solução é implementar o throttle no próprio script, independente do launchd. O mecanismo é simples: o script grava um timestamp em um arquivo de controle após cada backup bem-sucedido. Na próxima execução, antes de fazer qualquer coisa, ele lê esse timestamp e calcula quanto tempo se passou. Se o intervalo for menor que o limite definido — cinco minutos, por exemplo — o script encerra imediatamente com uma mensagem de log e código de saída 0. O launchd vê a execução como bem-sucedida e volta a dormir até a próxima modificação no banco.

O resultado é um sistema de duas camadas: o launchd garante que o script seja chamado quando o banco mudar, e o script garante que o backup efetivo não aconteça mais do que uma vez a cada cinco minutos. O launchd cuida da reatividade, o script cuida da contenção. Separar as responsabilidades dessa forma mantém o plist simples e a lógica de negócio onde ela pertence — no script, onde pode ser testada, ajustada e versionada independentemente da configuração do sistema operacional.

Cenário 2: otimização automática de imagens#

O problema#

Formatos de imagem modernos como WEBP e AVIF oferecem compressão significativamente melhor que PNG e JPG sem perda perceptível de qualidade. Um PNG de 2 MB vira um AVIF de 200 KB com resultado visual indistinguível; um JPG de câmera com 5 MB cai para menos de 1 MB em WEBP. A diferença é grande o suficiente para importar em qualquer contexto onde imagens são servidas — blogs, portfólios, documentação, até anexos de email.

O problema é que ninguém quer abrir um conversor manualmente toda vez que salva uma imagem. Ferramentas gráficas como o Squoosh existem e funcionam bem para uma ou duas imagens, mas a conversão precisa virar hábito para ter impacto real, e hábitos que dependem de passos manuais morrem na segunda semana. O que funciona é tirar o humano do processo: salvar a imagem num diretório específico, e a conversão acontecer sozinha.

No Linux, a solução clássica seria um inotifywait em loop dentro de um script monitorando a pasta, ou um path unit do systemd ativando um service. No macOS, o WatchPaths do launchd faz o mesmo trabalho com menos peças móveis: aponte para o diretório, e o launchd avisa quando algo aparecer.

A lógica do script#

O script monitora um diretório — por exemplo, ~/Pictures/optimize/ — e processa qualquer arquivo PNG ou JPG que encontrar nele. Para cada imagem encontrada, ele gera uma versão AVIF (ou WEBP, dependendo da preferência), verifica se a conversão foi bem-sucedida, e remove o original. O diretório funciona como uma inbox: arquivos entram num formato, saem em outro.

A conversão em si usa ferramentas de linha de comando instaláveis via Homebrew. O cwebp (do pacote webp) converte para WEBP; o avifenc (do pacote libavif) converte para AVIF. Ambos aceitam parâmetros de qualidade que permitem ajustar o balanço entre tamanho e fidelidade visual — um valor de qualidade entre 75 e 85 costuma ser o ponto ideal para imagens fotográficas, produzindo arquivos dramaticamente menores sem artefatos visíveis a olho nu.

O script precisa tratar um detalhe que parece menor mas causa problemas reais: arquivos que ainda estão sendo escritos. Quando um navegador salva uma imagem grande ou um aplicativo exporta um PNG de alta resolução, o arquivo aparece no diretório antes de estar completo. Se o script disparar nesse momento e tentar converter uma imagem parcialmente escrita, o resultado vai ser um AVIF corrompido e o original deletado. A proteção mais simples é verificar se o arquivo parou de crescer — comparar o tamanho, esperar um ou dois segundos, comparar de novo — antes de iniciar a conversão.

O plist#

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.janio.image-optimizer</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/janio/bin/optimize-images.sh</string>
    </array>
    <key>WatchPaths</key>
    <array>
        <string>/Users/janio/Pictures/optimize</string>
    </array>
    <key>StandardOutPath</key>
    <string>/Users/janio/.local/log/image-optimizer.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/janio/.local/log/image-optimizer.log</string>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
</dict>
</plist>

Diferente do cenário anterior, aqui o WatchPaths aponta para um diretório, não para um arquivo específico. Faz sentido: o objetivo é detectar a chegada de novos arquivos, e a criação de um arquivo é uma modificação no diretório que o contém. O launchd dispara o script quando qualquer coisa muda em ~/Pictures/optimize/, e o script decide o que fazer — processar PNGs e JPGs, ignorar todo o resto.

O /opt/homebrew/bin no PATH é onde o cwebp e o avifenc moram depois de instalados pelo Homebrew. Sem essa variável, o script não encontraria os conversores.

Cuidados com loops e filtros#

Existe uma armadilha óbvia nessa configuração que precisa ser tratada no script, não no plist: o próprio script cria arquivos novos no diretório monitorado. Quando ele converte foto.png para foto.avif, o AVIF que aparece em ~/Pictures/optimize/ é uma modificação no diretório — e o launchd dispara o script de novo. Se o script não filtrar os arquivos por extensão antes de processá-los, ele vai tentar converter o AVIF que acabou de criar, falhar ou gerar um resultado absurdo, e potencialmente entrar num loop infinito de execuções.

A solução mais limpa é o script processar exclusivamente arquivos com extensões .png, .jpg e .jpeg, ignorando qualquer outra coisa. Um find com filtro de extensão no início do script resolve isso em uma linha. Se o find não retornar nada, o script encerra imediatamente — a execução foi disparada por uma mudança que não é da conta dele, como a criação de um .avif ou a remoção de um arquivo já processado.

Uma alternativa mais robusta é usar dois diretórios separados: um de entrada onde as imagens originais são salvas, e um de saída onde os arquivos otimizados são gravados. O WatchPaths monitora apenas o de entrada, e o script nunca escreve nele — só lê e apaga. Essa separação elimina completamente a possibilidade de loop, ao custo de uma pasta extra na estrutura. Qual abordagem usar depende do fluxo de trabalho: se o objetivo é arrastar imagens para uma pasta e encontrá-las otimizadas no mesmo lugar, o filtro por extensão basta. Se o script faz parte de um pipeline maior onde outros processos leem a saída, a separação em dois diretórios é mais segura.

O throttle do launchd também aparece aqui, mas com implicações diferentes do cenário de backup. Se você arrastar dez imagens para a pasta de uma vez, o launchd dispara o script na primeira mudança e depois aplica o intervalo de 10 segundos antes de permitir uma nova execução. Mas isso não é um problema na prática: o script não processa um único arquivo por execução — ele varre o diretório inteiro com o find e processa tudo que encontrar. As dez imagens que chegaram juntas são todas convertidas na primeira execução. O throttle só afetaria o caso em que novas imagens continuam chegando enquanto a conversão das anteriores ainda está rodando, e mesmo assim a próxima execução pegaria tudo que ficou pendente.

WatchPaths vs. QueueDirectories#

O launchd tem uma segunda chave para monitoramento de diretórios que aparece pouco na documentação e menos ainda em tutoriais: QueueDirectories. A sintaxe é idêntica à do WatchPaths — um array de caminhos absolutos — mas a semântica é diferente de uma forma que importa para certos fluxos de trabalho.

O WatchPaths dispara o job quando detecta qualquer modificação nos caminhos monitorados. Não importa o que mudou nem se o caminho ainda existe depois da mudança — o evento aconteceu, o job roda. Se o script executado apagar o arquivo que disparou o evento, o launchd não se importa. Se o diretório monitorado estiver vazio depois que o script terminar, o launchd não se importa. O trabalho dele é detectar a mudança e executar o comando; o que acontece depois é problema do script.

O QueueDirectories adiciona uma condição: o job só dispara quando o diretório monitorado não está vazio. E mais — se o diretório ainda não estiver vazio quando o script terminar, o launchd executa o job de novo. O ciclo se repete até que o diretório fique vazio, momento em que o launchd volta a dormir e aguarda a próxima chegada de arquivos. É, literalmente, uma fila: itens entram, o job processa, e só para quando a fila esvazia.

A diferença parece sutil mas muda a forma como o script precisa ser escrito. Com WatchPaths, o script tipicamente varre o diretório, processa tudo que encontrar e termina. Se algo novo chegar durante a execução, o launchd dispara outra execução depois que a atual terminar (respeitando o throttle). Com QueueDirectories, o script pode se dar ao luxo de processar um único item por execução, porque o launchd vai chamá-lo de novo automaticamente enquanto houver itens pendentes. Esse modelo simplifica o script — nada de loops, nada de find — ao custo de mais invocações do processo.

Para o cenário de otimização de imagens, QueueDirectories seria uma escolha natural se usarmos a abordagem de dois diretórios. O diretório de entrada funciona como fila: imagens chegam, o script converte uma por vez (ou todas de uma vez, tanto faz), move os resultados para o diretório de saída, e o launchd continua chamando o script até que a entrada esteja vazia. A vantagem sobre WatchPaths nesse caso é que não há risco de perder arquivos que chegaram durante uma conversão demorada — o QueueDirectories garante que o job rode de novo se ainda houver trabalho a fazer.

Para o cenário de backup do SQLite, QueueDirectories não faz sentido. O banco de dados nunca “esvazia” — ele é um arquivo que existe permanentemente e muda de conteúdo. A lógica de fila não se aplica a um arquivo que está sempre presente. WatchPaths é a escolha correta quando o que importa é o evento de modificação, não a presença ou ausência de conteúdo num diretório.

Na prática, WatchPaths é a ferramenta de uso geral e cobre a maioria dos casos. QueueDirectories brilha em pipelines onde o diretório funciona como caixa de entrada descartável — processamento de uploads, conversão de formatos, ingestão de arquivos — e onde a garantia de que nenhum item fique para trás importa mais do que a simplicidade do plist.

O que fica para os próximos posts#

Este post mostrou a mecânica do WatchPaths e do QueueDirectories, os plists que fazem tudo funcionar, e as armadilhas que precisam ser tratadas no script — throttle, loops, arquivos parcialmente escritos, a diferença entre monitorar um arquivo e monitorar um diretório. O que ficou deliberadamente de fora foram os scripts em si.

O script de backup do SQLite envolve decisões que merecem espaço próprio: a ordem correta de operações para garantir consistência do banco, o checkpoint do WAL, a construção do staging directory, e a configuração do rclone com um remote no Backblaze B2. Cada uma dessas peças tem suas pegadinhas, e enfiar tudo numa seção de um post sobre launchd diluiria tanto o launchd quanto o backup.

O script de otimização de imagens abre um caminho parecido. A escolha entre WEBP e AVIF não é óbvia — AVIF comprime melhor mas codifica mais devagar, e nem todo contexto aceita os dois formatos. Os parâmetros de qualidade do cwebp e do avifenc têm comportamentos diferentes que afetam o resultado final. E a lógica de proteção contra arquivos incompletos, que parece trivial descrita em um parágrafo, tem mais nuance na implementação do que o conceito sugere.

O script de conversão de imagens já está pronto — código completo com fallback entre encoders, detecção de sistema operacional e proteção contra arquivos incompletos. O launchd é o gatilho; o que ele dispara é onde mora a complexidade real.

Se você usa Linux, escrevi o equivalente deste post com systemd path units e inotifywait — os mesmos cenários, adaptados para o ecossistema Linux.