project · macos · swift

Tick-Tock for Mac — recording the meetings you attended, without ever hearing one

A menu-bar app that writes the calls you actually took onto your calendar, by watching which application is holding the microphone. It never records audio — and it never asks for microphone permission, because it has no way to use one.

Aug 2026 v0.5.0 · Swift 6 · SwiftPM AppKit · SwiftUI · CoreAudio · EventKit Apple Silicon · macOS 14.4+ 437 tests Apache-2.0 built with Claude Code

My calendar is a record of meetings that were scheduled. It is a poor record of meetings that happened. The half of my week that lives in ad-hoc Slack huddles leaves no trace at all, and the half that was scheduled includes things I declined, missed, or left after four minutes.

So: reconstruct the day from signals the machine already emits, and let a human confirm the result. Same premise as the Android app of the same name. On a phone the richest signals are notifications, call logs and geofences. On a Mac there is one that beats all of them — which application is holding the microphone. Nothing else so cleanly separates "I was in a call" from "I had Zoom open".

Two apps, one name, on purpose. Tick-Tock on Android reconstructs a day from what a phone leaks; this one does it from what a Mac leaks. They share the premise and nothing else — no code, no sync, no account between them. Both were built under the working name Activity Hub, which is still what the repos and directories say, for a reason further down that turns out not to be laziness.

It never records audio, and could not if it wanted to

This is the first thing to establish, because "an app that knows when you are in meetings" sounds exactly like an app that is listening.

Detection reads a CoreAudio property that describes which processes currently hold the microphone. That is metadata about the audio system, not audio. There is no capture code in the app, no NSMicrophoneUsageDescription in the bundle, and consequently no microphone prompt — macOS never offers the grant because the app never asks, and it could not use it if granted.

The distinction is worth stating precisely: the app knows Zoom held the microphone from 14:02 to 14:47. It does not know, and has no mechanism to learn, a single word that was said.

The cost is a hard floor of macOS 14.4. Per-application microphone attribution comes from CoreAudio process objects, which do not exist before that release, and there is no degraded fallback — an earlier macOS can tell you the microphone is in use but not by whom, which is precisely the part that matters. It also cannot run headless or in CI: detection reads the local audio process list and needs permissions that only exist for a logged-in user on a real Mac.

You can check the claim rather than take it, which is the only reason it is worth making. The shipped bundle declares two usage descriptions and no third:

Tick-Tock.app/Contents/Info.plist
NSCalendarsUsageDescription    = …adds an event for each meeting it detects…
NSAppleEventsUsageDescription  = …checks whether your browser has a Meet call open…
LSMinimumSystemVersion         = 14.4
LSUIElement                    = true

# and the one that is absent:
NSMicrophoneUsageDescription   — not present

An app with no microphone usage description cannot be granted microphone access by macOS at all. The privacy claim is enforced by the platform, not by my good intentions.

The microphone was never the hard part

Watching CoreAudio turned out to be about 200 lines against a well-behaved API. The actual problem is deciding which microphone activity constitutes a meeting, and every rule in that decision is a threshold or an ordering question over timestamps:

The obvious implementation — a class that reads Date(), holds mutable state, and mutates it from CoreAudio listener callbacks — makes every one of those verifiable only by sitting through real meetings and waiting. The project's own risk assessment had already named the real microphone signal as its top risk, meaning those thresholds were expected to need repeated retuning against observed data. At a day per retuning cycle, that is not a design, it is a sentence.

So SessionEngine is a pure value type. Its inputs are immutable snapshots that carry their own timestamp; its outputs are sessions. It performs no I/O, reads no clock, and imports nothing from CoreAudio, the journal, the calendar writer or EventKit. Completion-by-elapsed-time is an explicit flushExpired(at:) call rather than an internal timer.

The part I would defend hardest is the enforcement mechanism, which is one line of Package.swift:

Package.swift
// SessionEngine depends on Support and NOTHING else. Ever.
.target(name: "SessionEngine", dependencies: ["Support"]),

Reaching for the clock inside the engine is therefore a compile error rather than a review comment. A clock protocol injected into an impure engine was the obvious middle road and I rejected it: it leaves the door open to the engine acquiring I/O later, and the whole test strategy rests on that door being shut. The dependency edge is what makes the guarantee durable instead of aspirational — the ADR recording it exists mostly so that a future me, who will find adding Date() there entirely harmless-looking, is told otherwise.

The payoff is the whole rule set exercised over synthetic timelines that would take hours to live through:

swift test
✔ Test "a mute toggle does not split one call into two events" passed
✔ Test "draining twice does not write the same session twice" passed
✔ Test "a pending session is written on the next startup, not duplicated" passed
✔ Suite "Journal-before-calendar ordering (AC7)" passed
✔ Test run with 437 tests in 100 suites passed after 1.019 seconds

The concurrency case that worried me most — a dictation tool interleaving through a 30-minute huddle — is six function calls. Retuning a threshold means editing one struct and rerunning the suite. 6,300 lines of tests against 8,500 lines of source is a ratio I would normally be suspicious of; here it is just what it costs to test timing logic without waiting for time to pass.

Two thresholds that only real meetings could set

Both defaults were wrong, and both were changed by using the thing rather than reasoning about it:

ThresholdWas → isWhat went wrong
Minimum duration 120s → 60s Three genuine Meet calls measured 90s, 95s and 115s. All three were discarded as "not meetings".
Merge gap 180s → 15s A 180-second window merged two consecutive Slack huddles with different people into one 71-minute event.

The second is the more interesting error, because it is asymmetric. Recording two conversations as one is worse than recording one call as two: the first is a false claim about my day, the second is a tidying job. Once framed that way the gap belongs near the bottom of its plausible range, not the middle. The trade-off is explicit and I live with it — muting for longer than 15 seconds ends the session, so a long mute yields two events.

Invalid configuration values fall back to the default with a warning rather than to zero, because a typo'd threshold silently becoming 0 disables exactly the filtering it was there to configure.

A closed allowlist beats a clever heuristic

Plenty of things hold a microphone without being meetings: dictation tools, voice memos, a messenger playing a voice note. The first instinct is a heuristic — duration thresholds, mic-hold patterns, something inferential.

Instead: a closed allowlist of four entries. Slack, Zoom, Teams, and Google Meet in a browser. Anything absent is ignored. Dictation is excluded with no rule and no heuristic, because it was never included.

Two things make that work in practice. The first is that the engine holds no global "microphone is busy" state — sessions are keyed per app and are entirely independent. This is why dictation-during-a-call is correct by construction rather than by special case: the dictation tool is filtered at resolution, and there is no shared state left for it to corrupt.

The second is that matching is on bundle ID prefixes, not exact IDs, and that is not sloppiness. Electron and Chromium apps hold the microphone from a renderer or helper process:

swift run Probe
com.tinyspeck.slackmacgap.helper     ← Slack's actual mic holder
com.google.Chrome.helper             ← Chrome's
us.zoom.caphost                      ← Zoom's capture host

Prefixes match up to a dot boundary, so com.tinyspeck.slackmacgap catches the helper but not a hypothetical slackmacgapX. A browser additionally needs needsBrowserCheck, which corroborates against the actual tab list — Chrome holding the microphone means nothing until a tab looks like a live call. Firefox is unsupported and permanently so: it exposes no scriptable tab list, so it can never be corroborated.

The failure mode of a closed allowlist is an app quietly changing its bundle ID, which would look identical to "you had no meetings today". So unmatched microphone use is appended to unlisted.jsonl. That file exists for one reason: an allowlist gap should be visible rather than silent.

No name is better than a wrong name

An event reading Zoom call is nearly worthless three weeks later. The useful version names who it was with. There are two routes to that, and one trap.

The route that works beautifully applies to exactly one app: Slack titles its huddle window Huddle: @Someone, so reading the window title (via Accessibility) turns an ad-hoc huddle into Slack huddle — Tushar Yadav. Every other conferencing app exposes no useful window title whatsoever.

So everything else is named from the matching calendar invite — and this is where the trap is. Plain time-overlap matching is not merely imprecise, it is actively harmful, and it is the easy thing to write. Measured against my real calendar: every single recorded session overlapped a day-spanning room booking called "Room for Deployment/Discussions" with ten attendees on it. Several also overlapped a personal "Lunch" block. Naive matching would have confidently labelled every call I take with the same ten people.

The matcher therefore rejects candidates rather than ranking them loosely. An event is not a viable match if it is all-day, has no attendees (a personal block, not a meeting), runs longer than four hours (a room booking, which swallows everything inside it), or is someone else's meeting I merely have visibility of. What survives must also start within 15 minutes of the call — the strongest available signal, where duration is a weak one, since a two-minute appearance in a thirty-minute meeting is a legitimate match. Closest start wins; ties break toward closest duration, which separates a 30-minute invite from a 3-hour one starting at the same moment.

One more rule, small and load-bearing: the attendee list excludes me. That is what makes a one-to-one identifiable by the other person's name — with several people the event takes the meeting's own title, because listing four attendees in a calendar title is unreadable and picking one is arbitrary.

When nothing survives, the event is named Zoom call and stays that way. The governing principle is that no name is better than a wrong name, and a calendar full of confidently wrong attributions is worse than one with some blanks.

Rounded to the quarter hour, and the call that rounds to nothing

A call starts when the microphone opens — 1:58, 2:03 — but a calendar is read by people, and "2 to 3" beats "1:58 to 3:09". Both ends round to the nearest quarter.

Nearest, not outward. Always expanding would inflate every meeting by up to half an hour and turn a day of short calls into a day that looks full.

Rounding is anchored to the containing hour in the local calendar, not to absolute epoch seconds. Every current time zone is offset by a whole number of quarter hours, so epoch arithmetic happens to agree — but it agrees by coincidence, and in a zone offset by 5:53 it would round to times no clock displays. Getting that right costs one line and removes a class of bug that would have been unfindable.

Which leaves the case I like most. A call too short for a quarter collapses to a single point: 12:59 to 13:03 rounds to 13:00 at both ends. Such an event cannot be written — an event needs an end after its start — so the sheet offers Discard instead. Two alternatives were tried and both were worse: pushing the end to the next quarter reported a three-minute call as fifteen minutes, and keeping the raw times made the app's one rounding rule inconsistent for exactly the calls least worth keeping. A range that rounds to nothing is the app saying the call was too short to be worth a quarter of an hour, and Discard is the honest response to that.

Discard has a nice asymmetry in it. It removes the meeting from the app and deletes its event, after confirmation — but the journal keeps the record, marked discarded. The microphone genuinely was held; what I rejected was the meeting being reported, which is a different claim from it not having happened.

The journal always keeps what was actually observed. Only the calendar event gets the tidied times, with the original shown struck through above the editable fields — and only when rounding genuinely moved something, rather than printing the same time twice for a call that already started on a quarter.

Writing to a calendar exactly once

Everything about calendar writing is shaped by the fact that a duplicate event is a real, visible, annoying bug, and the app runs unattended.

The append-only journal is the idempotency source of truth, and it is written before the calendar — an ordering with its own test suite. A session interrupted mid-write is picked up on the next launch and written once, not twice. Pressing "add" twice cannot create two events. Turning automatic adding back on backfills every meeting journalled while it was off, rather than silently applying only to future calls.

The constraint I set and did not relax: it never touches an event it did not create. It adds events, reads them, and can retitle, retime or delete the ones it wrote — identified by the event identifier stored in its own journal at write time, which is also why events written under the app's old name are still recognised as its own. Someone else's event is never modified or deleted, ever.

The reverse case is handled too, and is the one people actually hit. Delete a written event from Calendar and the app notices on the next refresh, drops the tick, and offers to add it again — but will not re-add it on its own, because deleting it was a decision, not an accident to be helpfully undone.

The destination is chosen by the user and nothing is written until it is, because it is a privacy decision that is genuinely theirs to make: a local "On My Mac" calendar never leaves the machine, iCloud is personal but syncs to Apple, and a work account is the most useful place to reconstruct a working week and is visible to colleagues — with event titles that include who the call was with. The app never creates a calendar, and never writes anywhere it was not pointed.

Similarly, the calendars shown on the timeline start unticked. The alternative default, showing everything, turns granting Calendar access into a dashboard full of every event from every account — which reads as the app having overstepped, and would be right.

The line the activity timeline does not cross

Alongside meetings, the dashboard shows the day as 96 quarter-hour rows: which app I was using in each block, labelled with the one used most, with a red line at the current time.

That timeline is deliberately never written to a calendar, and the reason is not technical. Dozens of events a day would bury the real meetings — but more than that, a minute-by-minute record of what you used is a materially different privacy proposition from "you were in a call". One is a meeting log; the other is surveillance with your own name on it. It renders in the app's own window, has no export path, and a single block can be added by hand if I want it.

Three details in that grid took more thought than they look:

There is also an honest hole: no history from before you install it. macOS exposes no record of which app was frontmost in the past, so the timeline starts on first run. Past scheduled meetings come from the calendar and so work retroactively, which makes the asymmetry look like a bug and it isn't one.

What a rename cannot change

The app was called Mac Activity Hub and is now Tick-Tock. Three things kept the old name, because renaming them destroys data or permissions rather than relabelling anything:

Only CFBundleExecutable was renamed, which is what Activity Monitor displays. A rename is a UI change; identity is not, and the two get confused because they usually look the same.

Permissions have one more macOS-shaped consequence worth recording. Grants are tied to an app's code signature, and ad-hoc signing produces a new signature on every build — so every recompile would look like a new app and silently drop the Calendar and Accessibility grants. Hence a self-signed certificate created in the login keychain, trusted for code signing only, no sudo and no system-wide trust. It exists purely so that rebuilding does not cost you your permissions, and not for distribution at all.

Installing it, and why the first launch is a refusal

That self-signed certificate has a consequence for anyone else: macOS refuses the app the first time, and correctly. It has never been through Apple's notarisation service, because notarisation requires the paid Developer Program. Here is exactly what your Mac sees, and none of it is a defect:

what Gatekeeper reports
$ spctl --assess --type execute -vv Tick-Tock.app
Tick-Tock.app: rejected
origin=Mac Activity Hub Dev

$ codesign -dvv Tick-Tock.app
Authority=Mac Activity Hub Dev
TeamIdentifier=not set

$ xcrun stapler validate Tick-Tock.app
Tick-Tock.app does not have a ticket stapled to it.

$ codesign --verify --deep --strict -v Tick-Tock.app
Tick-Tock.app: valid on disk
Tick-Tock.app: satisfies its Designated Requirement

Rejected, a self-signed authority, no team identifier, no notarisation ticket — the honest signature of software nobody bought a certificate for. Worth weighing before running it, the same as anything else distributed this way. The last two lines are the ones that matter for diagnosis: the signature is intact. That is why you get the ordinary "cannot verify the developer" block, which has an Open Anyway button, rather than "Tick-Tock is damaged and can't be opened", which is the message for a broken signature and has no such button. If you ever see "damaged", the download truncated — fetch it again.

The order matters, and this is the part every set of instructions gets wrong. The Open Anyway button is created by the blocked launch. It is not a standing setting. Go looking in System Settings first and there is nothing there, which reads as the instructions being wrong rather than as being early.

So: double-click it and let it be refused, clicking Done rather than Move to Trash. Then System Settings → Privacy & Security, scroll to the Security section at the bottom, and a line has now appeared offering Open Anyway. Authenticate, confirm once more, and that is it — once per Mac, and again per new version you download. The entry does not linger indefinitely, so if it is missing, launch the app again and look immediately.

On macOS 15 and later that is the only route. Control-clicking an app and choosing Open was removed, so any older instructions describing that simply do not work any more. On a managed work Mac, MDM policy can remove the Open Anyway option altogether — build from source there instead, which sidesteps the whole dance because an app you compiled locally was never quarantined.

Two hard requirements before any of that, one of which fails in the worst possible way:

RequirementWhyIf you don't have it
Apple Silicon The build ships a single arm64 slice Nothing happens at all. Rosetta cannot help — it translates Intel to Apple Silicon, not the reverse
macOS 14.4+ CoreAudio process objects, the entire detection mechanism Will not run. There is no fallback
A calendar account Meetings are written through EventKit Runs fine, but has nowhere to write

The Intel case is the one that bothers me: an arm64-only binary on an Intel Mac fails silently rather than explaining itself. That is a packaging decision I should revisit — a universal binary costs build time and nothing else.

Declining a permission is never a dead end, which took more work than granting one. The app asks once — re-asking at every login, which it does start at, is nagging — and afterwards a Permissions tab shows what is missing with a button that opens the exact Privacy & Security pane. That matters most for Calendar, which macOS prompts for exactly once and never again, making that button the only way back. Accessibility is stranger still: macOS permits no dialog that grants it, so the "prompt" is only a redirect to System Settings, and the app watches for the moment you flip it.

Tech, and where it stands

architecture
CoreAudio → AudioProcessKit → MicMonitor → SessionEngine → Journal
            (C interop)       (listeners)  (PURE logic)  → CalendarWriter
                                                         → Dashboard

Swift 6 on SwiftPM, no Xcode project. The split between the two UI frameworks fell out along an honest seam: AppKit owns the shell — status item, window lifecycle, the main menu, and a Dock icon that appears only while a window is open — because all of that is exactly the window-management work SwiftUI is worst at on macOS. SwiftUI owns what is inside the windows, the dashboard and settings, which is the declarative-list-of-rows work it is best at. It is an accessory app (LSUIElement), so there is no Dock presence until it earns one.

Ten targets, split so the pure one can be kept pure. Three append-only JSONL files — sessions, unlisted microphone use, and one activity file per day — where a single bad line is skipped rather than poisoning the file, and all of them safe to delete, because the app recreates what it needs.

Nothing is written to a calendar until you pick a destination, and then only automatically if you also tick Add them automatically, which is off by default. With it off the app still detects and journals everything; you add meetings one at a time. Turning it on later backfills what was journalled while it was off.

Six releases so far, currently v0.5.0, in daily use on my own machine. Everything above that reads as a considered design decision was, in practice, a bug I hit first: the merged huddles, the discarded 90-second calls, the ten-attendee room booking attached to every call, the three-minute meeting that could not be saved, and a locked Mac reporting a productive night.

Licensed Apache-2.0 rather than MIT, deliberately. MIT would permit taking it, renaming it and republishing it as your own, which is the one thing the licence is here to prevent. Apache-2.0 requires attribution, requires stating what changed, and grants no rights to the name — section 6 covers trademarks, so the code may be reused while "Tick-Tock" may not be reused to describe a derivative. None of which actually prevents copying: a signed binary on someone else's Mac is entirely under their control and can be renamed and re-signed in three commands. What the licence does is make stripping authorship a deliberate act rather than an oversight, which is the most any licence achieves.

The repository is currently private, so there is no source link or download above — the write-up is the public artefact for now.

On the microphone, once more. The detection signal is a CoreAudio property listing which processes hold the mic. There is no audio capture, no transcription, no meeting content, and no microphone permission — not as a policy, but as a property of how it works.