I study every day, and the phone is the main thing that pulls me out of it. Before this project I had a different setup: studying earned time to spend on social media, and the phone stayed locked most of the day. It had two problems. It turned study into a currency, and unlocking depended on a computer being on. A blocker that you cannot turn off when you need to is a trap, and a trap is a bad product even when you are the only user.
So I started over with the opposite goal: protect windows of focus, and never lock the phone in a way that depends on anything else. Foco is the result, in two apps.
The two halves
On the Mac, a SwiftUI app: I choose a task from my roadmap (13 phases, 138 topics, 553 micro tasks), start a session or a Pomodoro cycle, and when it ends I write what I did. A session with a report turns into a star in the constellation of that topic, and the streak only counts a day with a session and a report. Reports come back later as spaced reviews, at 1, 3, 7, 21 and 60 days.

On the phone, Foco Guard in Kotlin: it applies the policy the Mac sends. During a session it blocks everything except the essentials, in a work window it blocks only the apps I selected, and outside both it does nothing.
The Mac is the only owner of the data. The phone is a client that always starts the conversation, keeps the last policy it received and applies it by itself with exact alarms. That means a sleeping Mac does not unblock anything and a dead one does not either.
A protocol written before the code
Two apps in two languages need a contract, and I wrote it first: docs/PROTOCOL.md is the source of truth, and any change goes through it, with fixtures and test vectors updated. There are two endpoints. GET /v1/policy returns the work window, the planned windows and the active session. POST /v1/sessions lets the phone send a session it ran by itself, and it is idempotent by id, so resending never duplicates it on the Mac.
The transport is HTTP/1.0 in plain text over TCP, with no TLS. The content is only a study schedule, so what matters is who wrote a message and whether it was changed on the way, and that is what the signature gives:
X-Sig = hex( HMAC-SHA256( secret, "REQ\n" + METHOD + "\n" + PATH + "\n" + TS + "\n" + NONCE + "\n" + hex(SHA256(body)) ) )The Mac refuses a request with a bad signature, a timestamp more than 300 seconds off, or a nonce seen in the last 600 seconds. The response is signed too, with a different formula that uses the request’s nonce:
X-Sig = hex( HMAC-SHA256( secret, NONCE + "\n" + body ) )The client has to check it and discard the response if it does not match. This matters more than it looks: without it, any device on the Wi-Fi could answer for the Mac with a policy that unlocks (or locks) the phone. The secret has 128 bits and is shown as a 26-character base32 pairing code. The Mac only serves addresses from the local network, and generating a new code unpairs every phone.
Discovery uses Bonjour (_foco._tcp, port 47830). The phone remembers the last ip:port that worked and only searches again if it fails. When the router blocks mDNS, it scans the /24 subnet on that port, and only a signed response counts as an answer.
The contract has test vectors computed separately in Python. Both the Swift server and the Kotlin client have to reproduce them byte by byte, and each side has tests against the same fixtures. Every JSON body has "v": 1, readers ignore unknown fields, and an incompatible change gets a new path (/v2/...) while any client still uses the old one.
What the phone does when the Mac is gone
The phone has a small state machine, and its design came from the rule I started with:
- Mode is
ON,PAUSED(until)orOFF. OFFsuspends nothing.PAUSEDsuspends nothing until the time runs out, then goes back to blocking.ONwith an active session blocks everything (except the essentials). Without a session, a work or planned window blocks the selected apps. Otherwise nothing is blocked.- Pause (15 min, 1 h, 3 h, until tomorrow) and Off need no network, no Mac and no confirmation. Each unlock goes to a local log, for a weekly summary, but it adds no friction.
A session is valid from startedAt to endsAt including the breaks: the phone stays locked through a Pomodoro rest, and the segments field is only for display. The phone also fixes its own clock: the Mac sends serverTime, and the phone adjusts the start and end of the session by the difference.
Blocking without root, and what I did when I could not trust it
The engine blocks apps with pm suspend, run through Shizuku. There is no root, because my banking apps do not accept it, and I also avoided accessibility services, overlays and the permission to list all packages. The engine sits behind an Enforcer interface and records exactly what it suspended (suspendedByUs), so unlocking releases only that and nothing the user had suspended before.
My plan flagged a risk right away: Shizuku with wireless debugging seemed to drop when the screen locks. If that happens, the phone cannot block anything, and a blocker that lies about it is worse than none. So before writing the engine I planned short experiments on the real device, each with a pass criterion: does it survive a locked screen, does a focus screen over the lock keep it alive, does pinning the screen work as a real lock, and does a work profile do the job. The criteria all included the same line: releasing has to work without the Mac and without Wi-Fi, with no risk to the banks.
One rule came out of that and stayed in the code. If Shizuku stops, the app says “no protection”. It never pretends to be locked.
Building the Mac app without Xcode
The Mac machine only has the Command Line Tools, so the project is a SwiftPM package with a packaging script, and it opens in Xcode later with no rework. It is split in three parts:
FocoCore: pure rules, with no interface. Sessions, Pomodoro, streak, goals, spaced review, planner and the policy builder. This is what most of the tests cover, withswift-testing.FocoApp: the SwiftUI interface, with the constellation drawn inCanvas.- A server on
Network.frameworkplus Bonjour, tested with real sockets and with a probe tool (foco-probe) that talks to it from outside.
The constellation is a pure function of the state: a phase of the roadmap is a region of the sky, a topic is a constellation, and each session with a report is a star. Its position comes deterministically from the session’s id and its brightness from the minutes. A dropped session is a star that goes out.


What I learned
- Write the contract before the code, with test vectors from a third implementation. Two languages agreeing on a document is a weaker guarantee than two languages agreeing on the same bytes.
- Sign the response, not only the request. Anyone on the network can answer in the server’s place if the client does not check.
- If a feature is a restriction on the user, design the way out first. Everything else in the state machine followed from “unlocking depends on nobody”.
- When a component might not hold, put it behind an interface, test it on the real device before building on it, and make it fail honestly.
Foco is a personal tool, built for my own routine, so the design favors what I need over what a general product would. If you are curious about a part of it (the protocol, the state machine or the constellation), tell me and I will write about that part.