find my device: the design, and what it can never do
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.
This commit is contained in:
@@ -421,6 +421,9 @@ explaining why it was safe.
|
||||
|
||||
- `docs/navigation-audit.md` — **authoritative** on routing/navigation: the opaque-click anti-pattern,
|
||||
a severity-ranked findings table, the channel-selection map and the four-phase plan
|
||||
- `docs/find-my-device.md` — **design, not built**: locating your own devices from Officer. What is
|
||||
assembly (most of it), what is impossible (offline finding — there is no crowd to relay it), and the
|
||||
question to settle first, which is retention rather than anything technical
|
||||
- `docs/affordances.md` — what an affordance is, the three tests for whether something qualifies (including
|
||||
the one that catches most bad ones: _can it be fixed instead?_), the file format, and how they reach the
|
||||
agent. Read before writing one
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# 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. See `docs/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 `online` and `lastSeen` per 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:
|
||||
|
||||
1. **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 in
|
||||
`docs/push-notifications.md`, and it is the reason silent pushes were rejected for notifications.
|
||||
2. **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.
|
||||
3. **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_devices` answers "where do I send a push" — churny, plural per device, token-keyed.
|
||||
- `devices` answers "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
|
||||
|
||||
- **`Always` location authorization** plus the `location` background mode and
|
||||
`allowsBackgroundLocationUpdates`. `When In Use` is 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 (`claimIosProfile` in
|
||||
`api/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 `?selected` from the URL, not from a channel. Reuse the maplibre setup from
|
||||
`plugins/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
|
||||
|
||||
1. **`devices` table, core**, with the on-device UUID, and `push_devices.device_id` joined to it. This is
|
||||
worth doing even if find-my never ships — the push registry currently has no stable device identity.
|
||||
2. **Report endpoint + `device_locations`.** Batched, both timestamps, accuracy required.
|
||||
3. **Hub app: significant-change reporting.** The floor, and on its own already a working find-my.
|
||||
4. **The panel** — device list, map, last-known position.
|
||||
5. **Retention job.** Before real data accumulates, not after.
|
||||
6. **Play a sound.** The highest value-per-effort command by a distance.
|
||||
7. **On-demand locate**, accepting it is best-effort.
|
||||
8. Android foreground service, folded in with the push fallback if that is ever built.
|
||||
9. 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.
|
||||
Reference in New Issue
Block a user