---
title: "Coordenando Apps iOS Irmãos Sem Rede"
date: 2026-06-15
tags: [swift, darwin-notifications, iOS, combine, sdk, kernel-ipc]
lang: pt-BR
---

# Coordenando Apps iOS Irmãos Sem Rede

Um SDK em Swift que transforma as Darwin notifications em um barramento de heartbeat e mensagens entre apps iOS no mesmo aparelho — sem servidores, sem App Groups, sem entitlements.

## Dois apps. Um aparelho. Nenhuma resposta boa.

Imagine dois apps no mesmo iPhone que precisam conversar. Mesmo aparelho, quase sempre do mesmo fornecedor, muitas vezes abertos pelo mesmo usuário com poucos segundos de diferença. E o iOS te oferece pouquíssimo para resolver isso. As App Groups cobram uma ginástica de _entitlements_ e um _container_ compartilhado. As Push notifications exigem uma ida e volta ao APNs que você não quer pagar. As URL schemes só disparam com o app em primeiro plano. O _pasteboard_ é um campo minado de privacidade. E a descoberta de rede local agora pede permissão explícita e uma declaração de serviço Bonjour — o que coloca na sua frente, na hora, um usuário que (com toda razão) desconfia de qualquer app pedindo para "encontrar dispositivos na sua rede".

Eu precisava de outra coisa. Algo que funcionasse com os dois apps em memória, que não passasse pelos servidores da Apple, não exigisse _entitlements_, não fizesse aparecer nenhuma folha de permissão e não se importasse se algum dos apps tinha acesso à internet. Só dois processos, um _kernel_ e uma pergunta mútua: _você está vivo? Toma aqui um payload._

Este post é sobre um SDK que escrevi, o **InternalNotification**, que pega um mecanismo de _kernel_ pouco conhecido — as Darwin notifications — e o transforma num barramento _pub/sub_ estruturado, com _heartbeats_ de _liveness_ e entrega de mensagens JSON arbitrárias. Minha intenção era construir um _framework_ de _pub/sub_ reutilizável, e ele acabou me ensinando mais sobre estado do _kernel_, _threading_ no Combine e o teto de 4 KB do que eu imaginava aprender naquela semana.

## As Darwin notifications, em poucas palavras

Caso você nunca tenha esbarrado nas Darwin notifications: é a mesma engrenagem por trás do `UIApplicationDidBecomeActiveNotification`, dos avisos de mudança de _dark-mode_ e de uma centena de outros eventos que valem para o sistema inteiro. Por baixo dos panos, o `NSNotificationCenter.darwinNotifyCenter` é só um _wrapper_ Cocoa fininho em volta de três funções C do `<notify.h>`:

```cpp
// notify.h
notify_register_dispatch(name, &token, queue, handler);
notify_post(name);
notify_set_state(token, value);
notify_get_state(token, &value);
```

Qualquer um no dispositivo pode postar uma notification por nome. Qualquer um ouvindo por esse nome recebe um callback. Cada token registrado também carrega um _state_ de 64 bits que você pode ler ou escrever a partir de qualquer processo que detenha um token para o mesmo nome. Esse estado sobrevive entre leituras — o kernel o mantém por perto enquanto pelo menos um token estiver aberto.

Essa última frase é toda a razão pela qual este SDK existe. Um registrador de 64 bits gerenciado pelo kernel e endereçável por nome de string é, se você apertar os olhos e ir mais fundo, apenas uma pequena célula de memória compartilhada, entre milhares delas.

## Primeiro, os pings

_Pub/sub_ sem identidade é só ruído. A primeira tarefa era deixar os apps se anunciarem e responderem à pergunta que todo par coordenado uma hora faz: _quem está acordado agora?_ Todo cliente do InternalNotification em execução posta um _ping_ num canal compartilhado a cada três segundos. O _payload_ do _ping_ é pequeno, mas diz muito.

```swift
// Heartbeat.swift
private func sendPing(config: INAppConfig) {
    announceIdentity(config: config)

    let timestamp = UInt32(Date().timeIntervalSince1970)
    let state = (UInt64(fingerprint) << 32) | UInt64(timestamp)

    var pingToken: Int32 = 0
    notify_register_check(channel, &pingToken)
    notify_set_state(pingToken, state)
    notify_post(channel)
    notify_cancel(pingToken)
}
```

O valor do _state_ é um par `(fingerprint, timestamp)` empacotado. O `fingerprint` é um _hash_ CRC32 do _bundle identifier_ de quem envia, o que me dá um ID pseudoúnico de 32 bits sem precisar de nenhum registro combinado de antemão. Na prática, dois apps sem relação nenhuma nunca colidem; e, se colidissem, o segundo registro só veria _timestamps_ mais antigos e se ressincronizaria.

Do lado de quem recebe, um monitor acorda a cada _ping_, decodifica o _state_ e atualiza um conjunto `aliveApps` indexado pelo _fingerprint_. Os apps que perdem `timeout` intervalos seguidos (o padrão são 10 segundos) caem fora do conjunto. E é esse conjunto que todo consumidor do SDK acaba observando.

O interessante não é o _ping_ em si. Ele não carrega identidade nenhuma além do _fingerprint_ — nada de _bundle ID_, nada de _API key_, nenhum dos metadados que um app consumidor de fato quer. Então, como é que um monitor recém-iniciado descobre a qual app pertence o _fingerprint_ `0xC3F1A902`? Com resolução preguiçosa.

Quando o monitor topa com um _fingerprint_ que ainda não conhece, ele faz uma leitura única num conjunto _diferente_ de _tokens_ que quem envia vem mantendo, caladinho, desde que subiu. Esses _tokens_ são a carteira de identidade de quem envia: `bundleID|apiKey`, picado em _slots_ de 8 bytes e escrito uma única vez, na hora em que o remetente sobe.

```swift
// Heartbeat.swift
private func announceIdentity(config: INAppConfig) {
    let payload = "\(config.identifier)|\(config.apikey)"
    let bytes = Array(payload.utf8)
    let chunkCount = (bytes.count + 7) / 8

    var countToken: Int32 = 0
    notify_register_check("\(identifier).\(fingerprint).count", &countToken)
    notify_set_state(countToken, UInt64(chunkCount))
    announceTokens.append(countToken)

    for i in 0..<chunkCount {
        var token: Int32 = 0
        notify_register_check("\(identifier).\(fingerprint).\(i)", &token)

        let start = i * 8
        let end = min(start + 8, bytes.count)
        var value: UInt64 = 0
        for (j, byte) in bytes[start..<end].enumerated() {
            value |= UInt64(byte) << (56 - j * 8)
        }
        notify_set_state(token, value)
        announceTokens.append(token)
    }
}
```

O _kernel_ mantém esses _tokens_ vivos porque o remetente segura _handles_ abertos para eles em `announceTokens`. Quem recebe abre, lê o _state_ e fecha. Custo zero de _broadcast_, zero negociação de segredo compartilhado. Quem envia escreve uma vez, ao subir; quem recebe lê uma vez, no primeiro contato, e guarda o resultado em _cache_.

É essa assimetria — quem empurra empurra _fingerprints_, quem puxa puxa identidade — que responde pela maior parte da leveza do SDK.

## Furando o teto de 4 KB

Os _pings_ são fáceis porque cada um cabe em 64 bits. As mensagens, não. O uso mais natural depois do _liveness_ — fazer _broadcast_ de um _payload_ estruturado, tipo um _snapshot_ de telemetria do aparelho — estoura rapidinho o tamanho de um único valor de _state_ do Darwin.

O `notify_set_state` recebe um `UInt64`. Oito bytes. Se você quer mandar um _blob_ JSON de 6 KB, vai precisar de outro plano. A resposta "oficial" é: não faça isso — troque por _mach messages_ ou um _socket_ UNIX. Só que, no instante em que parti para um _socket_, eu já estava reconstruindo justamente a API da qual tinha vindo fugir. Então fui pelo caminho de lado.

Cada nome de notification é um _token_ só dele. Abra milhares deles, trate o conjunto como um _ring buffer_ paginado dentro do _kernel_, e pronto: você converteu um mecanismo de célula única num transporte na escala de 32 KB. O `MessageSender` pré-aloca um _pool_ de 4.096 _tokens_ ao subir, cada um guardando 8 bytes — um total de 32 KB de estado de _kernel_ endereçável por remetente.

```swift
// MessageSender.swift
func send(_ messages: [INMessage]) -> Bool {
    guard let jsonData = try? JSONEncoder().encode(messages) else { return false }
    guard let compressed = compress(jsonData) else { return false }

    let bytes = Array(compressed)
    let chunkCount = (bytes.count + 7) / 8
    guard chunkCount <= poolSize else { return false }

    notify_set_state(countToken, UInt64(chunkCount))
    notify_set_state(sizeToken, UInt64(jsonData.count))
    notify_set_state(compressedSizeToken, UInt64(compressed.count))

    for i in 0..<chunkCount {
        let start = i * 8
        let end = min(start + 8, bytes.count)
        var value: UInt64 = 0
        for (j, byte) in bytes[start..<end].enumerated() {
            value |= UInt64(byte) << (56 - j * 8)
        }
        notify_set_state(pool[i], value)
    }

    var signalToken: Int32 = 0
    notify_register_check("\(channel).msg", &signalToken)
    let state = (UInt64(fingerprint) << 32) | UInt64(chunkCount)
    notify_set_state(signalToken, state)
    notify_post("\(channel).msg")
    notify_cancel(signalToken)
    return true
}
```

O fluxo é assim:

1. Codifica as mensagens para JSON.
2. Comprime com LZFSE. O JSON costuma encolher de 2 a 5 vezes, o que devolve boa parte do orçamento de 32 KB. O LZFSE já vem embutido no sistema — é determinístico, rápido, sem dependência de terceiros e sem pressão no alocador.
3. Quebra o fluxo de bytes comprimido em _chunks_ de 8 bytes, empacota cada um em _big-endian_ dentro de um `UInt64` e escreve no _pool_ de _tokens_ pré-alocado.
4. Escreve três _tokens_ pequenos de metadados — `chunkCount`, originalSize e compressedSize — para que quem recebe consiga validar e descomprimir com segurança.
5. Posta uma única notification de sinal em `<central>.msg`. O _state_ desse sinal leva `(senderFingerprint, chunkCount)`, o que permite a quem recebe tanto filtrar as próprias mensagens quanto saber exatamente quanto precisa ler.

Quem recebe faz o caminho inverso: lê `chunkCount` _tokens_, remonta o fluxo de bytes, descomprime, decodifica e publica.

O difícil não foi o _chunking_ — isso é um _buffer_ paginado, e _buffer_ paginado é terreno mais do que pisado. O difícil foi o sequenciamento. As Darwin notifications têm entrega eventual: se você posta o sinal antes de todos os _chunks_ estarem escritos, quem recebe rápido pega lixo. Por isso eu sempre escrevo todos os _chunks_ primeiro, depois o _state_ do _token_ de sinal e _só então_ posto. Quem recebe sempre vê um _snapshot_ coerente, nunca um pela metade.

Também coloquei _throttling_ no lado de quem envia, porque disparar cinquenta mensagens num laço apertado se mostrou uma ótima forma de transbordar a fila de notifications do _kernel_.

```swift
// Messenger.swift
public func send(_ messages: [INMessage]) {
    let batches: [[INMessage]]
    if messages.count <= maxMessagesPerTransfer {
        batches = [messages]
    } else {
        batches = stride(from: 0, to: messages.count, by: maxMessagesPerTransfer).map {
            Array(messages[$0..<min($0 + maxMessagesPerTransfer, messages.count)])
        }
    }

    sendQueue.async { [weak self] in
        for (i, batch) in batches.enumerated() {
            if i > 0 { Thread.sleep(forTimeInterval: 0.3) }
            _ = self?.sender.send(batch)
        }
    }
}
```

Cada lote de até 50 mensagens sai, e aí eu durmo por 300 ms. É um _rate-limit_ tosco, mas proposital — a fila de notifications do Darwin não é infinita, e um laço apertado de 500 _posts_ começa a jogar evento fora. Os 300 ms escoam bem em todo aparelho em que testei.

## A API que o seu app realmente chama

Até aqui, tudo viveu na terra dos _callbacks_ em C. _Tokens_, _dispatch queues_, empacotamento bit a bit. A superfície pública, de propósito, não tem nada disso. O `INCenter.start` inicializa o SDK uma vez; daí em diante, enviar é _fire-and-forget_ e receber é uma única _closure_ registrada. Eis toda a troca entre dois apps cooperando no mesmo aparelho.

```swift
// POSApp.swift
INCenter.start(
    identifier: "com.vendor.shared",
    config: INAppConfig(identifier: "com.vendor.pos", apikey: "team-...")
)

INCenter.shared?.messenger.send([
    INMessage(kind: "order.created", payload: [
        "id": "ord_42",
        "items": 3,
        "total": 49.90,
    ])
])
```

É esse o lado de quem produz, inteiro. Sem `await`, sem _completion handler_, sem `try`. O `.send` codifica o _payload_, comprime, deposita os _chunks_ no _pool_ de _tokens_ e posta o sinal. Se houver alguém vivo escutando o canal, a mensagem chega dentro do mesmo ciclo de _dispatch_; se não houver, ela é jogada fora. _Fire-and-forget_ é o contrato — igualzinho às próprias Darwin notifications.

```swift
// KitchenApp.swift
INCenter.start(
    identifier: "com.vendor.shared",
    config: INAppConfig(identifier: "com.vendor.kds", apikey: "team-...")
)

INCenter.shared?.messenger.onReceive { messages in
    for msg in messages where msg.kind == "order.created" {
        Kitchen.queue(msg.payload)
    }
}
```

O _callback_ te entrega um `[INMessage]` já decodificado na _main thread_ e retorna. Nenhum _token_ de assinatura para segurar, nenhuma limpeza manual quando o app é suspenso — o `INCenter.stop()` cancela todo _token_ Darwin aberto numa chamada só. Existe um _callback_ irmão, o `onPresence`, para mudanças de pares guiadas pelo _ping_, e uma sobrecarga `send(_:to:)` que filtra pelo _fingerprint_ de quem envia quando você quer uma mensagem dirigida em vez de um _broadcast_. Tudo isso se apoia na mesma engrenagem: empacota, posta, joga fora.

A superfície pública inteira cabe num cartãozinho de consulta rápida que você poderia colar ao lado do monitor: `start`, `stop`, `send`, `onReceive`, `onPresence`. É esse o SDK.

## Onde ele brilha

O primeiro encaixe de verdade foi um arranjo de dois iPads num restaurante _fast-casual_. Um iPad roda o ponto de venda no balcão; o caixa registra o pedido, passa o cartão, imprime o recibo. Um segundo iPad fica num suporte na cozinha, rodando um Kitchen Display System. Os dois ficam no mesmo Wi-Fi da loja — só que esse Wi-Fi também carrega o tráfego do terminal de pagamento, uma rede de convidados e, vez ou outra, uma Apple TV no meio de uma atualização de _firmware_. Mandar todo pedido pela LAN funcionava, na maior parte do tempo. A latência no percentil 95 ficava em três a quatro segundos, com uma cauda de doze segundos toda vez que o _access point_ renegociava. Para um pedido que sai do balcão e vai para a linha da cozinha, é a diferença entre uma passagem suave e um cozinheiro perdido, esperando uma comanda que já foi fechada do lado do cliente.

E o detalhe decisivo: o POS e o KDS estavam sempre rodando num par grudado. Mesmo fornecedor (nós), mesma identidade de assinatura, mesmo _hardware_ comprado junto, montados a um metro um do outro. Toda aquela ida e volta pela rede resolvia um problema que, no fundo, não existia — os dois apps estavam na mesma sala, nas mesmas mãos, dividindo um _hub_ Lightning. Então joguei os dois num único iPad, com o InternalNotification no meio, e o fio encolheu para um aparelho só. A latência caiu de alguns segundos para algo consistentemente abaixo de cinquenta milissegundos, e "o Wi-Fi tá estranho de novo" deixou de ser uma categoria de chamado de suporte.

A integração completa, de cada lado, cabe num guardanapo.

```swift
// POSApp.swift
INCenter.shared?.messenger.send([
    INMessage(kind: "order.fired", payload: order.toDictionary())
])
```

```swift
// KitchenApp.swift
INCenter.shared?.messenger.onReceive { messages in
    for msg in messages where msg.kind == "order.fired" {
        kitchenQueue.append(Ticket(from: msg.payload))
    }
}

```

Sem HTTP. Sem cabeçalho de autenticação. Sem _retry_. Sem _socket_. Dois apps sob uma única conta de desenvolvedor conversando através do _kernel_, num aparelho que os dois já têm na mão.

O mesmo formato cobre todos os casos vizinhos que apareceram desde então: um app companheiro acoplado mostrando o estado ao vivo do app principal, um display voltado ao cliente espelhando a tela do atendente, um app de _overlay_ de _debug_ que assina os eventos de qualquer outro app do fornecedor sem que esses apps precisem saber que ele existe. Desde que o nome do canal seja compartilhado, simplesmente funciona.

## Concessões

Isso aqui não é mágica. As restrições são reais e vale a pena dar nome a elas.

**Só no mesmo aparelho.** As Darwin notifications não atravessam máquinas, não sobrevivem a um AirDrop, não ajudam com Handoff. Se os seus apps precisam se coordenar entre aparelhos, esta não é a ferramenta.

**32 KB por transporte.** O _pool_ de 4.096 _tokens_ é dimensionado para _payloads_ típicos de telemetria. _Payloads_ maiores precisariam de vários _pools_ ou de outro mecanismo — e, a essa altura, talvez seja melhor partir para um _container_ compartilhado de verdade, gravado em arquivo.

**Sem garantia de entrega.** As Darwin notifications são _best-effort_. Se quem recebe está suspenso, ou se a fila está quente, os _posts_ são fundidos ou descartados. O SDK usa _pings_ e _tokens_ de sinal para detectar _liveness_, mas eu não finjo que isso seja mensageria confiável. Para isso, você precisa de _acks_. Cheguei a pensar em pôr um canalzinho de _ack_ e concluí que os usos que tenho hoje ainda não pagam essa complexidade.

**Limites de background do iOS.** As Darwin notifications só disparam enquanto o app está vivo o bastante para recebê-las. Um app em _background_ ou suspenso não recebe nada. O SDK é para aquela janela de sobreposição entre o _foreground_ e o ativo-mas-não-em-primeiro-plano — que é exatamente quando apps de fornecedores que se coordenam costumam precisar dele.

**Colisões de fingerprint.** Um CRC32 de 32 bits colide com probabilidade nada desprezível ao longo de milhares de apps sem relação. Dentro da suíte de apps de um único fornecedor — o uso real — a colisão é praticamente impossível. Se um dia eu lançasse isso como _framework_ geral, trocaria o CRC32 por um _hash_ mais longo e um pequeno _handshake_ para detectar incompatibilidades.

**Os callbacks caem na main.** Todo disparo de `onReceive` e `onPresence` salta para a _main queue_. É seguro e confortável, mas significa que um par barulhento pode empurrar trabalho para a _main thread_. O SDK limita a frequência do _ping_ e aplica _rate-limit_ nos envios em lote para manter a fila tranquila; um adversário deliberado no mesmo aparelho ainda conseguiria fazer um DoS, mas o modelo de ameaça aqui são apps amigáveis, do mesmo fornecedor, sob a mesma identidade de assinatura — e isso se sustenta.

Nada disso é arrependimento. É o contrato: um canal de coordenação na casa do sub-milissegundo, sem rede e sem permissão, para apps _que cooperam_ sob o controle de um mesmo desenvolvedor. Dentro desse contrato, ele é rápido, limpo e confortável de usar.

## Para fechar

O SDK acabou menor do que eu esperava — menos de mil linhas de Swift — mas se apoia numa fundação bem mais antiga e bem mais esquisita. As Darwin notifications são quase invisíveis na comunidade de desenvolvimento iOS hoje; boa parte da documentação que as menciona é da era do OS X 10.4. Mexer com elas foi um pouco como achar uma instrução não documentada, mas estável, numa CPU: está ali desde sempre, ninguém vai tirar, mas pouquíssima gente usa.

Se um dia você se pegar tentando coordenar dois processos iOS sem passar pela rede, dê uma olhada no `<notify.h>`. Tem mais coisa ali do que a documentação do iOS deixa transparecer. O _kernel_, como sempre, é uma camada mais interessante do que o SDK que a gente põe por cima.

## Referências

- Apple — `notify_post` e a man page do `notify(3)` (`man 3 notify` no macOS) para a API completa das Darwin notifications.
- Apple — [Compression framework / LZFSE](https://developer.apple.com/documentation/compression), o compressor embutido usado na transmissão.
- Apple — [Combine](https://developer.apple.com/documentation/combine), usado internamente para coordenar o estado antes de ele virar _callbacks_ simples.
- Apple — [Darwin Notify Center via DistributedNotificationCenter](https://developer.apple.com/documentation/foundation/distributednotificationcenter) para o irmão macOS do mesmo mecanismo.
