Field Notes
13 min de leituraAtualizado 17 de junho de 2026

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.

  • swift
  • darwin-notifications
  • iOS
  • combine
  • sdk
  • kernel-ipc
connecting apps

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 kernelthreading 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>:

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.

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.

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)
    }
}

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.

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.

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.

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. Tokensdispatch 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.

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.

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)
    }
}

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: startstopsendonReceiveonPresence. É 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.

POSApp.swift
INCenter.shared?.messenger.send([
    INMessage(kind: "order.fired", payload: order.toDictionary())
])
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

Compartilhar esta notaBaixar PDFMarkdown