Turn your AirPods case into a pointer for your Mac.
Move the closed case to move the cursor. Tap its front to click.
| Effect | What happens |
|---|---|
| Case data | Ending a session uses up the diagnostic data stored on the case. |
| AirPods settings | Some AirPods connection settings stay as the session set them. We do not know yet if this change, or the case data change, lasts after the Bluetooth link resets. |
| Audio | Each connection drops the AirPods' Bluetooth link for a moment, and their audio stops. Do not connect during a call. |
| Microphone | During a session, the app keeps Mac sound and the microphone off all AirPods. It restores them when the session ends. After a force quit, it restores them when you next open the app, if the AirPods are still connected. |
| Silent audio | During a session, the app plays silence to the AirPods to keep their audio link active. You do not hear it. |
| Cursor size | A cursor smaller than 2× grows to 2× while you point. It returns to its size when you pause or disconnect. After a force quit, it returns when you next open the app. This uses a private macOS call. |
| Item | Status |
|---|---|
| AirPods Pro 3, macOS 26, Apple Silicon | Tested on one Mac with one pair |
| AirPods Pro 2 | Recognized, not tested |
| AirPods paired with your Mac | Required |
| Xcode | Required to build |
Normal connect and disconnect have run many times on that Mac and pair. These cases have not been tested on purpose:
- Force quit during a session
- Sleep and wake during a session
- Bluetooth lost during a session
- Calls and audio during and after a session
zsh ./build.sh
open AirPointer.appThe build also runs --self-test and the tap check. The other offline checks are
under For developers. Do not build during a pointer session.
If AirPointer does not appear in the Accessibility list after a rebuild, run:
tccutil reset Accessibility local.airpointerThe first time you open the app, setup has four steps.
- Allow: Drag AirPointer into the Accessibility list. This lets it move the cursor and click.
- Connect: Open the lid, close the lid, set the case down for a moment, and turn it upright.
- Calibrate: Point at three dots to show the app where your screen is.
- Tap: Tap the front of the case three times.
Press fn ⌃ in any app to connect. Press it again to pause or resume. To use a different shortcut, go to Settings.
On the tested AirPods, a session ends about 30 minutes after the lid closes. To continue, open and close the lid. See Known limitations.
The connect steps as a chart
flowchart TD
Start(["Press Connect or fn ⌃"]) --> Open["Open the lid"]
Open --> Close["Close the lid"]
Close --> Saved{"Sensor offset<br/>saved?"}
Saved -->|"No"| Hold["Set the case down"]
Saved -->|"Yes"| Turn["Turn the case upright"]
Hold --> Turn
Turn --> Edges{"Screen edges<br/>saved?"}
Edges -->|"No"| Dots["Point at three dots"]
Edges -->|"Yes"| Pointing(["Pointing"])
Dots --> Pointing
Pointing -->|"fn ⌃"| Paused(["Paused"])
Paused -->|"fn ⌃"| Pointing
Sessions end about 30 minutes after the lid closes. The app does not set this limit. At that point motion stops, and the AirPods stay unreachable until you open the lid. To continue, open and close the lid. In one test, reconnecting with the lid closed did not reset the limit.
The likely cause is timer 0x63 in the earbud firmware. We found it by reading
rkof.bin (H2, version 9A348), the firmware image for the tested AirPods Pro 3:
| Address | What it does |
|---|---|
0x17d2a4 |
Builds a status mask. It starts the timer if the mask is nonzero, the state check passes and the timer is not already running. It cancels the timer if the mask is zero. |
0x17d326 |
Loads the timer's length: 1,800,000 ms, or 30 minutes. |
0x17d294 |
The state check: the firmware's dock state equals 6. This probably means docked with the lid closed. |
0x17cddc |
Runs when the timer ends. It checks the state again, then calls a fatal assert that does not return. |
Measured sessions ended at the same time, about 1,805 seconds after the lid closed. This comes from reading the firmware and from those timings. No live capture has confirmed it yet.
| Problem | What to do |
|---|---|
| The app does not connect | Turn off Bluetooth on your iPhone. Your iPhone can hold the AirPods. |
| "Connecting" does not end | Open the lid and keep it open until the app tells you to close it. |
| The pointer lags or motion stops | Make sure no other app, such as OBS or a browser call, uses the AirPods as a microphone. |
| The pointer stutters just after you connect | This is normal for about 20 seconds. |
| "Set it down" takes a long time | Use a firm, flat surface and do not touch the case. |
| Taps do not click | Go to Settings > Teach Tap. Do this again if you change how you hold the case. |
flowchart LR
subgraph AirPods["AirPods Pro in the closed case"]
Sensors["Motion sensors"]
end
subgraph Helpers["Helper programs"]
Restore["ClosedCaseRestore<br/>brings the Bluetooth link up"]
Motion["ClosedCaseMotion<br/>starts the sensors and<br/>keeps the case awake"]
end
subgraph App["AirPointer app"]
Session["ClosedCaseSession<br/>runs the session"]
Math["Orientation.js<br/>motion to screen position"]
Tap["ClosedCaseTap<br/>finds taps"]
Guard["AudioOutputGuard<br/>keeps sound off the AirPods"]
UI["Window, menu bar,<br/>notch and setup"]
end
Mac["macOS<br/>moves the cursor and clicks"]
Restore -->|"checks the link"| Sensors
Sensors -->|"Bluetooth"| Motion
Motion -->|"motion data, through a pipe"| Session
Session -->|"heartbeat, every second"| Motion
Session --> Math
Session --> Tap
Math -->|"cursor position"| Mac
Tap -->|"click"| Mac
Session --> Guard
UI <-->|"state and commands"| Session
- Connect:
ClosedCaseRestoremakes sure the AirPods' ordinary Bluetooth link is up.ClosedCaseMotionthen reconnects them once, because the motion channel only answers just after a connection. - Stream:
ClosedCaseMotionstarts the sensors and sends paced requests to keep the closed case awake. It sends the motion data to the app. - Keep alive: The app sends a heartbeat every second. If the app stops, the helper stops the sensors and releases what it holds.
- Point:
Orientation.jscalculates the case's orientation and converts it to a screen position. - Click:
ClosedCaseTaplooks for a sudden movement in the direction of a tap. If it finds one, the app clicks and plays a short sound on the Mac.
If motion stops for less than two seconds, the pointer stops and then continues. If motion stops for longer, the pointer pauses and continues when motion returns.
Sources/
AirPointer/ The app
App/ Startup, the link to the windows, and the shortcut
Session/ The session, motion math, taps, audio guard, cursor size
Interface/ The window, menu bar, notch, setup and Tap Test
Checks/ Window and pose checks; other checks sit beside their code
ClosedCaseMotion/ The helper that talks to the AirPods
ClosedCaseRestore/ The helper that brings the Bluetooth link up
Tests/ Recorded taps and handling for the tap check
Assets/ Images, sounds and the 3D case model
Scripts/ Make the case and README images
Docs/ The design system
build.sh Builds AirPointer.app and runs the checks
Sources/ClosedCaseMotion/CaseSustainedRenewal.m lists the helper's exit codes at
the top. Docs/DESIGN-SYSTEM.md has the rules for the
interface. Assets/README.md explains the images and sounds.
Data on your Mac
The app keeps its data in ~/Library/Application Support/AirPointer/. It does not
send any of this anywhere.
| File | Contents |
|---|---|
closed-case-target.txt |
The AirPods to use |
closed-case-grip.json |
Saved screen edges |
closed-case-sensor-calibration.json |
Saved sensor offset, kept for one week |
PointerSessions/ |
Logs from the newest ten sessions |
TapTests.jsonl |
Tap Test scores |
During a session, the app's preferences also save which audio devices it moved. The saved names include the AirPods' Bluetooth address.
In a session's native.log:
STEPlines show the time of each connection step.HOLDlines show the motion count so far and its delay, about once a second.taplines explain each tap.- The
EXITline shows why the session ended.
Tap Test
Taps are tuned on one person so far, so your score may differ.
Connect, then go to File > Developer > Tap Test. The test takes about 75 seconds and tells you when to tap.
To also save a session's raw motion, run this before you connect. The recording has no size limit. Expect roughly 20 MB for a 30-minute session.
defaults write local.airpointer recordMotion -bool trueOffline checks
These checks do not use Bluetooth. --check-home opens real windows, so run it on
a logged-in Mac.
AirPointer.app/Contents/MacOS/AirPointer --self-test
AirPointer.app/Contents/MacOS/AirPointer --check-home
AirPointer.app/Contents/MacOS/AirPointer --check-pointer-settings
AirPointer.app/Contents/MacOS/AirPointer --check-closed-case-native
AirPointer.app/Contents/MacOS/AirPointer --check-audio-output-policy
AirPointer.app/Contents/MacOS/AirPointer --check-closed-case-tap Tests/TapFixtures.csv
AirPointer.app/Contents/Resources/ClosedCaseMotion.bin --self-testQuit AirPointer, delete this folder, then run:
rm -rf ~/Library/Application\ Support/AirPointer
defaults delete local.airpointer
tccutil reset Accessibility local.airpointer
tccutil reset BluetoothAlways local.airpointerThe code uses the MIT License. The license does not cover Assets/.
Apple owns the AirPods model that the case images are made from, and the sounds
have their own license. See Assets/LICENSE.md.


