Eu estudo todo dia, e o celular é o que mais me tira disso. Antes deste projeto eu tinha outro esquema: estudar rendia tempo para gastar em rede social, e o celular ficava travado quase o dia todo. Tinha dois problemas. Transformava o estudo em moeda, e o desbloqueio dependia de um computador ligado. Um bloqueador que você não consegue desligar quando precisa é uma armadilha, e armadilha é um produto ruim mesmo quando você é o único usuário.
Então recomecei com o objetivo oposto: proteger janelas de foco e nunca travar o celular de um jeito que dependa de outra coisa. O Foco é o resultado, em dois apps.
As duas metades
No Mac, um app em SwiftUI: escolho uma tarefa do meu roadmap (13 fases, 138 tópicos, 553 micro tarefas), começo uma sessão ou um ciclo de Pomodoro e, quando termina, escrevo o que fiz. Uma sessão com relato vira uma estrela na constelação daquele tópico, e a sequência de dias só conta um dia com sessão e relato. Os relatos voltam depois como revisão espaçada, em 1, 3, 7, 21 e 60 dias.

No celular, o Foco Guard em Kotlin: ele aplica a política que o Mac manda. Numa sessão ele bloqueia tudo menos o essencial, numa janela de trabalho bloqueia só os apps que selecionei, e fora das duas não faz nada.
O Mac é o único dono dos dados. O celular é um cliente que sempre inicia a conversa, guarda a última política que recebeu e a aplica sozinho, com alarmes exatos. Ou seja, um Mac dormindo não libera nada, e um Mac desligado também não.
Um protocolo escrito antes do código
Dois apps em duas linguagens precisam de um contrato, e eu o escrevi primeiro: o docs/PROTOCOL.md é a fonte da verdade, e qualquer mudança passa por ele, com fixtures e vetores de teste atualizados. São dois endpoints. O GET /v1/policy devolve a janela de trabalho, as janelas planejadas e a sessão ativa. O POST /v1/sessions deixa o celular mandar uma sessão que rodou sozinho, e é idempotente por id, então reenviar nunca duplica no Mac.
O transporte é HTTP/1.0 em texto puro sobre TCP, sem TLS. O conteúdo é só uma agenda de estudo, então o que importa é quem escreveu a mensagem e se ela foi alterada no caminho, e isso a assinatura garante:
X-Sig = hex( HMAC-SHA256( segredo, "REQ\n" + MÉTODO + "\n" + CAMINHO + "\n" + TS + "\n" + NONCE + "\n" + hex(SHA256(corpo)) ) )O Mac recusa um pedido com assinatura inválida, com timestamp mais de 300 segundos fora ou com nonce vista nos últimos 600 segundos. A resposta também é assinada, com outra fórmula que usa a nonce do pedido:
X-Sig = hex( HMAC-SHA256( segredo, NONCE + "\n" + corpo ) )O cliente precisa conferir e descartar a resposta se não bater. Isso importa mais do que parece: sem isso, qualquer aparelho no Wi-Fi poderia responder pelo Mac com uma política que destrava (ou trava) o celular. O segredo tem 128 bits e aparece como um código de pareamento em base32 de 26 caracteres. O Mac só atende endereços da rede local, e gerar um código novo desfaz todos os pareamentos.
A descoberta usa Bonjour (_foco._tcp, porta 47830). O celular guarda o último ip:porta que funcionou e só procura de novo se falhar. Quando o roteador bloqueia o mDNS, ele varre a sub-rede /24 nessa porta, e só uma resposta assinada vale como resposta.
O contrato tem vetores de teste calculados à parte em Python. O servidor em Swift e o cliente em Kotlin precisam reproduzi-los byte a byte, e cada lado tem testes contra as mesmas fixtures. Todo corpo JSON tem "v": 1, quem lê ignora campos desconhecidos, e uma mudança incompatível ganha um caminho novo (/v2/...) enquanto algum cliente ainda usar o antigo.
O que o celular faz quando o Mac sumiu
O celular tem uma máquina de estados pequena, e o desenho dela veio da regra com que comecei:
- O modo é
LIGADO,PAUSADO(até)ouDESLIGADO. DESLIGADOnão suspende nada.PAUSADOnão suspende nada até o tempo acabar, e depois volta a bloquear.LIGADOcom sessão ativa bloqueia tudo (menos o essencial). Sem sessão, uma janela de trabalho ou planejada bloqueia os apps selecionados. Fora disso, nada é bloqueado.- Pausar (15 min, 1 h, 3 h, até amanhã) e Desligar não precisam de rede, de Mac nem de confirmação. Cada destravamento vai para um registro local, para um resumo semanal, mas não cria atrito nenhum.
Uma sessão vale de startedAt até endsAt inclusive nas pausas: o celular continua travado no descanso do Pomodoro, e o campo segments serve só para exibir. O celular também corrige o próprio relógio: o Mac manda o serverTime, e o celular ajusta o início e o fim da sessão pela diferença.
Bloquear sem root, e o que fiz quando não dava para confiar
O motor bloqueia os apps com pm suspend, executado pelo Shizuku. Não há root, porque meus apps de banco não o aceitam, e também evitei serviços de acessibilidade, sobreposição e a permissão de listar todos os pacotes. O motor fica atrás de uma interface Enforcer e anota exatamente o que suspendeu (suspendedByUs), então destravar libera só isso e nada que o usuário já tivesse suspendido antes.
Meu plano apontou um risco logo de início: o Shizuku com depuração sem fio parecia cair quando a tela bloqueia. Se isso acontece, o celular não consegue bloquear nada, e um bloqueador que mente sobre isso é pior do que nenhum. Então, antes de escrever o motor, planejei experimentos curtos no aparelho de verdade, cada um com um critério de aprovação: sobrevive à tela bloqueada, uma tela de foco sobre o bloqueio mantém a depuração viva, fixar a tela funciona como bloqueio real, e um perfil de trabalho resolve. Todos os critérios tinham a mesma linha: liberar precisa funcionar sem o Mac e sem Wi-Fi, sem risco para os bancos.
Uma regra saiu daí e ficou no código. Se o Shizuku para, o app diz “sem proteção”. Ele nunca finge que está travado.
O app de Mac sem Xcode
A máquina do Mac só tem as Command Line Tools, então o projeto é um pacote SwiftPM com um script de empacotamento, e depois abre no Xcode sem retrabalho. Ele se divide em três partes:
FocoCore: regras puras, sem interface. Sessões, Pomodoro, sequência de dias, metas, revisão espaçada, planejador e o construtor da política. É o que a maior parte dos testes cobre, comswift-testing.FocoApp: a interface em SwiftUI, com a constelação desenhada emCanvas.- Um servidor em
Network.frameworkcom Bonjour, testado com sockets de verdade e com uma ferramenta de sonda (foco-probe) que conversa com ele de fora.
A constelação é uma função pura do estado: uma fase do roadmap é uma região do céu, um tópico é uma constelação e cada sessão com relato é uma estrela. A posição vem de forma determinística do id da sessão e o brilho vem dos minutos. Uma sessão desistida é uma estrela que se apaga.


O que aprendi
- Escreva o contrato antes do código, com vetores de teste de uma terceira implementação. Duas linguagens concordando com um documento é uma garantia mais fraca do que duas linguagens concordando com os mesmos bytes.
- Assine a resposta, não só o pedido. Qualquer um na rede pode responder no lugar do servidor se o cliente não conferir.
- Se uma funcionalidade é uma restrição para o usuário, desenhe a saída primeiro. O resto da máquina de estados saiu de “destravar não depende de ninguém”.
- Quando um componente pode não aguentar, ponha-o atrás de uma interface, teste no aparelho de verdade antes de construir em cima dele e faça-o falhar com honestidade.
O Foco é uma ferramenta pessoal, feita para a minha rotina, então o desenho favorece o que eu preciso mais do que um produto geral favoreceria. Se você tiver curiosidade sobre alguma parte (o protocolo, a máquina de estados ou a constelação), me diga e eu escrevo sobre ela.