sketched the same day push landed, because push is the piece that makes it possible at all — there is no other way to reach a sleeping phone. more of it exists than expected: the wake channel, a working maplibre map with clustering in the photos plugin, and headscale already reporting online and lastSeen per node, which covers laptops without an app at all. what is missing is a table, an endpoint, some app code and a panel. what it can never do is stated up front rather than discovered later. apple's offline finding works because a billion strangers' devices relay a lost phone's bluetooth beacon. there is no self-hosted version of a crowd. off-grid gets you last-known-position; powered-off gets you nothing. two things i expect someone to get wrong, so they are called out above the schema: - do NOT key location on the push token. apns tokens rotate, the registration path is already an upsert because of it, and a history keyed to one silently forks into two devices. this needs a stable on-device uuid and a `devices` table that is not push_devices — which is worth building anyway, since the push registry currently has no stable device identity at all. - store accuracy and draw the circle. ios returns 3km fixes when it is being cheap, and rendering one as a confident pin sends someone to the wrong street. the sharpest open question is not technical: the owner bypasses every permission check by design, so on a family server the owner can see a member's location structurally, unless location is given an explicit exception. that falls out of the architecture rather than out of anyone's intent, so it should be decided deliberately. retention likewise. a location table is a complete movement record, apple deliberately cannot build one, and self-hosting means choosing to. the rule belongs in the design, not in a settings screen added afterwards.
14 KiB
Find My Device, on Officer
Status: DESIGN, NOT BUILT. Sketched 2026-08-19, the same day push notifications went in — which is not a coincidence, because push is the piece that makes this possible at all.
What this is, and what it will never be
The pitch is not "replace Find My". It is "know where your devices are without Apple or Google being the ones who know." Location history on your own server, on your own tailnet, under your own retention rule. That part is mostly assembly.
The part that is genuinely impossible: offline finding. Apple's magic is not the map. It is that a lost iPhone with no network still broadcasts an encrypted identity over Bluetooth, and roughly a billion strangers' devices relay it home. That is a crowd, and you do not have one. Nor does any self-hosted system, ever.
So the honest boundary:
| Device has network | works — current position, on demand or on a schedule |
| Device is off-grid | last known position and nothing more |
| Device is powered off | nothing. Apple's is silicon-level; there is no software answer |
| Device is wiped | nothing |
A tracker that answers "where was it last, and when" covers the overwhelmingly common case — left in a taxi, lost in the house, stolen and still online. It does not cover the case Find My is famous for.
Anyone building this should be clear about that before starting, because the gap is not a to-do item.
What already exists
More than expected, which is why this is worth doing now rather than someday.
- The wake channel. You cannot reach a sleeping phone except through APNs/FCM. That is built,
officer/core:*codes and all, and a real device is registered. Seedocs/push-notifications.md. - A map.
plugins/photos/web/MapSection.tsx— maplibre-gl with markers, clustering, bounds-fitting, already working and already pinned to a version that behaves. Read its header comments before writing a second map; they record two traps that cost someone a day. - Device presence, free. offscale/headscale already reports
onlineandlastSeenper node (plugins/offscale/sidecar/normalize.ts). "Is my laptop awake, and when was it last seen" needs no new code at all — and it covers laptops and desktops, which the phone-app approach never will. - The tailnet, so a device reports home without anything being exposed publicly.
- The hub app, already installed, already registered, already receiving pushes.
What is missing is a table, an endpoint, some app code and a panel.
Architecture
Two paths, and they are not the same shape.
REPORT (device → server, most of the time)
phone ──POST /api/find-my/report──→ platform ──→ device_locations
COMMAND (server → device, rarely)
panel ──→ officer-notify ──push──→ hub app ──→ acts, then REPORTs
Everything of value flows on the report path. The command path exists only for "where is it right now", and it is the unreliable one — so the design must not depend on it.
The report path
The device posts a fix. That is the whole thing. It works when the app is foregrounded, when iOS hands it a background location update, and when a queued batch drains after a period offline.
Batching matters. A phone that has been off-grid for six hours has six hours of fixes. The endpoint
takes an array, and every fix carries its OWN timestamp from the device clock — which is why the schema
below separates recorded_at from received_at. Collapsing them loses the distinction between "you were
there at 14:00" and "the phone told me at 20:00", and that distinction is the entire value of a history.
Device clocks are wrong sometimes. Store both, trust neither blindly, and let the UI show recorded_at
while any "is this stale" logic uses the pair.
The command path, and why it is the weak one
To make a sleeping phone report now, you push. The options, in order of preference:
- Silent push (
content-available: 1), app wakes, takes a fix, posts it. The right shape — and Apple throttles these and does not guarantee delivery. Already established indocs/push-notifications.md, and it is the reason silent pushes were rejected for notifications. - A visible push the user taps. Reliable, but requires a human at the other end, which defeats the purpose when the phone is in a taxi.
- A Notification Service Extension taking the fix on delivery. Attractive because the NSE runs on a normal, reliable, high-priority push. Unverified — location access from an extension is possible in principle but unusual, and nobody here has tried it. Worth an afternoon before committing.
Design conclusion: treat on-demand as a bonus, never as the mechanism. If the background reporting is good, "where is it now" is answered by "where was it 4 minutes ago", and that is enough for almost every real situation.
Device identity — do NOT key this on the push token
The most likely serious mistake in this design, so it goes above the schema.
push_devices is unique on (token, bundle_id), and APNs tokens rotate. A location history keyed to
a rotating token silently forks into two devices, and the split looks like the user bought a new phone.
The registration path is already an upsert precisely because tokens churn.
So a device needs a stable identity generated once on the device at first launch and persisted across
token rotations, app updates and re-registrations — a UUID in the keychain, not derived from anything
Apple gives you (identifierForVendor resets when all your apps are uninstalled).
That means a devices table that is NOT push_devices:
push_devicesanswers "where do I send a push" — churny, plural per device, token-keyed.devicesanswers "which physical thing is this" — stable, one row per device, and what location, battery and presence hang off.
A push_devices.device_id foreign key joins them. This also makes a device that has never registered
for push (a laptop reporting over the tailnet) representable, which the push table alone cannot do.
Schema sketch
devices
id serial
user_id fk users
device_uuid text -- generated on-device, stable forever. unique with user_id
name text -- "André's iPhone", user-editable
platform text -- ios | android | macos | linux
model text -- reported, cosmetic
created_at, last_seen_at
device_locations
id bigserial
device_id fk devices
latitude double precision
longitude double precision
accuracy_m real -- ALWAYS store it; a 3km fix rendered as a pin is a lie
altitude_m real nullable
speed_mps real nullable
heading_deg real nullable
battery_pct smallint nullable
is_charging boolean nullable
source text -- significant | ondemand | visit | manual | geofence
recorded_at timestamptz -- the DEVICE clock, when the fix was taken
received_at timestamptz -- the SERVER clock, when it arrived
Index on (device_id, recorded_at desc) — every query is "latest for this device" or "this device
between two times", and both want that order.
accuracy_m is not optional. iOS returns fixes with 3km accuracy when it is being cheap about it, and
a UI that draws those as a confident pin will send someone to the wrong street. Draw the circle.
Battery belongs here, not in a separate table. "Last seen at 4%" is often the single most useful fact on the screen — it tells you whether to expect another fix at all.
The platforms
iOS
Alwayslocation authorization plus thelocationbackground mode andallowsBackgroundLocationUpdates.When In Useis not enough; a find-my that only works while you are looking at it is not one.- Significant-change monitoring is the floor: roughly cell-tower granularity, very cheap, survives app termination, and iOS relaunches the app to deliver it. This should be the primary source.
- The Visits API is cheaper still and gives arrival/departure at meaningful places. Good for history, useless for "it is moving right now".
- Continuous GPS is not an option for background use. It will eat the battery and iOS will fight you.
- The app is self-distributed, so App Store review of background location is not the obstacle it would otherwise be.
Android and GrapheneOS
Easier, and there is a pleasing irony here. The foreground service holding a tailnet socket — the
approach docs/push-notifications.md records as the fallback for GrapheneOS having no Play Services —
is close to ideal for this. It is always connected, so it needs no push to be told to report, and the
persistent notification that made it unattractive for notifications is a normal thing for a location
service to have.
So on the phone where push is hardest, find-my is easiest. One foreground service can serve both.
Laptops and desktops
Do not write a client. headscale already knows online and lastSeen per node, and the endpoint
addresses give a coarse network location. That is most of what anyone wants for a laptop, and it exists
today.
Commands, beyond locating
Each needs app code — the notification catalogue makes new notifications free, not new behaviours.
- Locate now — the weak path above.
- Play a sound. Genuinely useful, and the "lost in the house" case is the most common of all. Needs an audio session category that overrides the silent switch.
- Mark as lost — show a message and a contact number on the lock screen. Needs MDM on iOS.
- Lock / erase. Real MDM. Officer already serves iOS configuration profiles (
claimIosProfileinapi/dav/ios-profile), so this is a project rather than an absurdity — but it is a project, and it puts a device-wipe capability behind whatever authenticates to it. Think hard before building it.
Retention, and the thing to decide first
A location table on your own server is a complete movement record of your life. Apple deliberately cannot build one; you would be choosing to. That is a legitimate choice, and it is the whole point of self-hosting — but it means the retention rule is a design decision, not a settings screen to add later.
Decide before the table exists:
- A default retention window with an actual delete job, not a documented intention. 30 days is a reasonable starting point: enough to answer "where was I on Tuesday", short enough that a compromise is not a biography.
- A pause switch that stops reporting, visible on the device, honest about what it does.
- What a backup contains. The Postgres backup now includes a movement history. Anyone who copies a dump copies that.
- Who can see whom. Location is per-user and every query filters by
user_id— but the owner bypasses every permission check by design (permissions/authorize.ts). So on a family server the owner can see a member's location, structurally, unless location queries are given an explicit exception. That is a policy decision that has to be made deliberately, because the default falls out of the existing architecture rather than out of anyone's intent.
That last one is the sharpest question in this document.
Permissions
A new registry entry, kind: 'app' so it is grantable per role, with personal covering the report
endpoint so a member's own device can post its own fixes at read level — the same shape (and the same
trap) as notify, where the personal paths must be the real post-prefix paths. See
permissions/registry.ts and the notify regression test.
assertPermissionTotality will refuse to boot without the entry, which is the intended behaviour.
The web surface
Per the frontend route conventions in CLAUDE.md: a /find-my route rendering WorkspaceView, with
panels rather than a standalone screen.
- Device list — rows are real
<Link>s to/find-my?selected=<deviceId>, never onClick closures. - Map panel — reads
?selectedfrom the URL, not from a channel. Reuse the maplibre setup fromplugins/photos/web/MapSection.tsx. - History/timeline panel — a date range, also in the URL.
Selection is the URL. docs/navigation-audit.md is the authority and names the anti-pattern this must
avoid.
Core or plugin
Probably a plugin, unlike notify. The argument for notify being core was that its producers are the platform's own — the job queue, an agent turn ending. Nothing in the platform produces a location. It is a self-contained feature with its own table, its own panel and its own app support, which is the plugin shape exactly.
The counter-argument is that it needs a stable devices table that other things will want (push, presence,
per-device settings), and a core concept living in a plugin is how the notify mess started. Split it:
devices is core, find-my is a plugin that hangs off it.
Build order
devicestable, core, with the on-device UUID, andpush_devices.device_idjoined to it. This is worth doing even if find-my never ships — the push registry currently has no stable device identity.- Report endpoint +
device_locations. Batched, both timestamps, accuracy required. - Hub app: significant-change reporting. The floor, and on its own already a working find-my.
- The panel — device list, map, last-known position.
- Retention job. Before real data accumulates, not after.
- Play a sound. The highest value-per-effort command by a distance.
- On-demand locate, accepting it is best-effort.
- Android foreground service, folded in with the push fallback if that is ever built.
- MDM tier — separately, and only if genuinely wanted.
Open questions
- Owner visibility of a member's location. The default falls out of the permission architecture rather than out of intent. Decide it explicitly.
- Retention default, and whether it is per-device.
- Can an NSE take a location fix? Would make on-demand reliable. Unverified, worth an afternoon.
- Geofences — "tell me when the phone leaves the house" is cheap on both platforms and is arguably more useful than on-demand locating. Not in the build order above; it might deserve to be.