Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
25
Section 25 · follows 24

Bringing measurements into HealthKit, and parking settings on the mirror box

Written
2026-05-17
§ 25.1

HealthKit: only six things you can actually write

The first sub-question I had was simple: what does HealthKit even let me write? The app captures weight (from the scale), body-fat % (from the mirror’s CV), and thirteen circumferences (shoulder, chest, waist, stomach, hips, left/right upper-thigh, lower-thigh, calf, upper-arm). Surely most of those have a HKQuantityTypeIdentifier?

They don’t. The web is full of stale guesses, including a confidently-cited .hipCircumference and .chestCircumference that simply do not exist in the iOS SDK. The way to know for sure is to read the header directly:

$ grep -E "HKQuantityTypeIdentifier(Body|Waist|Height|Lean)" \ /Applications/Xcode.app/.../HealthKit.framework/Headers/HKTypeIdentifiers.h

Six body-composition types come back. Three the app measures directly:

  • bodyMass: weight, in kg
  • bodyFatPercentage: 0–1 (so divide my 0–100 stored value by 100 before sending)
  • waistCircumference: in meters

Three are derivable from data the app already has:

  • bodyMassIndex: weight / (height_m)², stored as a count unit (Apple’s convention)
  • leanBodyMass: weight × (1 − bodyFat/100)
  • height: straight from the user profile

Hip/chest/thigh/calf/arm circumferences are not representable in HealthKit. Those ten stay app-only. Nothing to be done; chasing it further would be pointless.

Writing: HKMetadataKeySyncIdentifier is the part you’d otherwise re-learn the hard way

The naïve write looks like this:

Fig. 25-1
swift
let sample = HKQuantitySample( type: HKQuantityType.quantityType(forIdentifier: .bodyMass)!, quantity: HKQuantity(unit: .gramUnit(with: .kilo), doubleValue: scan.weight), start: scan.createdAt, end: scan.createdAt)try await store.save(sample)

This works the first time. Then your scan pipeline has two paths: a “summary-only” insertion the instant scanState == .complete (weight + body-fat only, no zip yet) and an “upgrade” path that re-saves the same Scan a few seconds later with the rings.json mounted (waist, etc.). Both call the HK write hook. Without dedup, HealthKit ends up with two entries per scan: one identical to the first plus one richer one. The chart double-counts.

Fig. 25-2
sequenceDiagram
    participant Pipe as Scan pipeline
    participant HK as HealthKit
    Pipe->>HK: write sample (syncIdentifier = nm-{scanId}-weight), summary only
    Note over Pipe: a few seconds later, rings.json mounted
    Pipe->>HK: write sample (same syncIdentifier), upgraded with waist etc.
    Note over HK: same syncIdentifier replaces in place, one sample survives

HKMetadataKeySyncIdentifier is the fix: tag every write with a stable id derived from the scan id + the metric type, and HealthKit will replace-in-place instead of inserting:

Fig. 25-3
swift
let metadata: [String: Any] = [ HKMetadataKeySyncIdentifier: "nm-\(scan.id)-weight", HKMetadataKeySyncVersion: 1,]

Now the summary-path write and the upgrade-path write merge into a single sample, with the latest body of data winning. The version field is required alongside the identifier; I’ll bump it if I ever change what the identifier represents.

Reading: anchored queries, and filtering out your own writes

Importing existing HealthKit history into the app is a two-phase story.

Phase one is the bulk import: one shot, fires when the user enables the toggle. HKAnchoredObjectQuery with anchor: nil, limit: HKObjectQueryNoLimit returns every sample of a given type ever written by any source. For my three readable types (weight, body-fat, waist) that’s potentially years of data from an Apple Watch, a Withings scale, an Aria, a Garmin. The query returns an opaque HKQueryAnchor that I serialize and stash in UserDefaults:

Fig. 25-4
swift
if let data = try? NSKeyedArchiver.archivedData( withRootObject: a, requiringSecureCoding: true) { UserDefaults.standard.set(data, forKey: anchorKey(for: kind))}

Phase two is keep-current. Register an HKObserverQuery per type; the callback fires when something elsewhere in iOS adds a new sample. Inside the callback, run a new anchored query starting from the saved anchor, which returns only the delta. Insert the delta into my local HealthSample SwiftData cache, save the new anchor, done.

Fig. 25-5
sequenceDiagram
    participant App
    participant HK as HealthKit
    App->>HK: HKAnchoredObjectQuery(anchor nil) one-time bulk import
    HK-->>App: all historical samples + new anchor
    App->>App: save anchor to UserDefaults
    Note over App,HK: later, something elsewhere writes a new sample
    HK-->>App: HKObserverQuery callback fires
    App->>HK: HKAnchoredObjectQuery(anchor saved) delta only
    HK-->>App: just the new samples + updated anchor
    App->>App: insert delta into HealthSample cache, save new anchor

The bit you can miss on a first read: imports include samples you’ve written yourself, and if you don’t filter them out, every scan ends up as both a Scan row (rendered in the chart’s foreground series) and a HealthSample row (rendered in the imported-overlay series). The chart doubles. Filter by the source bundle id:

Fig. 25-6
swift
if qs.sourceRevision.source.bundleIdentifier == Bundle.main.bundleIdentifier { continue}

This is also how it should work for the user: the app’s own scans are the foreground series; “imports” should mean other sources of weight/body-fat/waist data.

The chart overlay

The history chart already had a series for the user’s own scans. I added a second translucent dashed series behind it for the imported HealthKit data:

Fig. 25-7
swift
LineMark( x: .value("Date", p.date), y: .value(metric.rawValue, p.value), series: .value("Source", "Health")).interpolationMethod(.monotone).foregroundStyle(AppTheme.secondaryText.opacity(0.5)).lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 2]))

The series: parameter is the part you need: without it, SwiftUI Charts treats the points from the two sources as a single series and the line zig-zags between them in date order, producing nonsense. With series: set to distinct strings, the renderer keeps them as separate connected lines.

The overlay only renders for the three metrics that exist in HealthKit (weight, body fat, waist). The hips/chest tabs are scan-only because the data only exists in my SwiftData store; HealthKit has nothing to overlay.

§ 25.2

The mock cloud as a third storage tier

Adding the healthKitEnabled flag raised an old question: what survives a TestFlight reinstall, or a “delete app + reinstall,” or a phone migration?

The three storage tiers the app already had:

  1. Local SwiftData / UserDefaults / Documents/. Instant read at launch. Survives app updates. Wiped on uninstall.
  2. Local Keychain. Same lifecycle. Survives because iOS keychain entries persist across re-signs as long as the team and bundle id match.
  3. iCloud Keychain. The same entries marked kSecAttrSynchronizable=true sync through Apple’s pipe. Survives uninstall and device wipe, syncs to other devices on the same Apple ID. This is how weightCalibrationFactor has always worked.

iCloud Keychain is good but has two soft spots:

  • It’s invisible to me. I can’t tell when it’s synced, what’s in it on any given device, or whether a particular field successfully made it across. The pipe is opaque.
  • Sync window is unbounded. I’ve watched it take a couple of minutes on a fresh restore. During those minutes, a freshly-reinstalled app boots with calibrationFactor = 1.0 and shows uncalibrated weight.

A self-hosted backup tier closes both. The mirror box on the LAN is reachable whenever you’re home, and home is the only place scans happen anyway, so the LAN-only constraint isn’t a real constraint. The existing naked-device-hash / X-User-Key header (a UUID stored in iCloud Keychain by UserKeyStore) gives me the user identity already. All that was missing was an endpoint.

Server-side: one route, two methods

The mock cloud already has the JSON-handler pattern from the inbox routes. Adding /user/settings was thirty lines on the Python side:

Fig. 25-8
python
def _user_settings_get(self) -> "tuple[int, dict]": key = self._user_key_header() if not key: return 400, {"error": "X-User-Key header required"} path = self._user_settings_path(key) if path is None: return 400, {"error": "invalid X-User-Key shape"} if not path.exists(): return 404, {"error": "no settings stored for this user"} return 200, json.loads(path.read_text()) def _user_settings_put(self, body: bytes) -> "tuple[int, dict]": key = self._user_key_header() if not key: return 400, {"error": "X-User-Key header required"} path = self._user_settings_path(key) if path is None: return 400, {"error": "invalid X-User-Key shape"} payload = json.loads(body or b"{}") payload["serverReceivedAt"] = now_iso() path.write_text(json.dumps(payload, indent=2)) return 200, payload

The “404 if user is new” return is a real return, not an oversight: the client uses it to decide whether to seed the cloud with local data or merge cloud into local. (More on that below.)

Path sanitization is non-negotiable here. The user-key becomes a filename, and even though my key is a UUID, the principle of treating client input as adversarial holds:

Fig. 25-9
python
@staticmethoddef _safe_user_key(user_key: str) -> "str | None": import re if not re.match(r"^[A-Za-z0-9_-]{1,128}$", user_key): return None return user_key

curl -k -H "X-User-Key: bad/../path" https://<cloud-ip>/user/settings returns 400. Good.

Client-side: last-writer-wins, with a timestamp the client controls

The cleanest reconciliation policy is last-writer-wins, with the client setting updatedAt at edit time (not the server at receive time). The server merely echoes back what it stored, plus a serverReceivedAt for log-correlation purposes.

ProfileStore.load() fires off the reconciliation in a Task after Keychain hydration completes:

Fig. 25-10
swift
case .notFound: // First time this user hits the cloud. Seed it with what I have. await pushToMockCloud(local)case .error: // LAN unreachable; local is still authoritative. returncase .found(let cloud): let cloudDate = MockCloudSettingsSync.parseStamp(cloud.updatedAt) ?? .distantPast if cloudDate > local.updatedAt { applyCloud(cloud, to: local) writeAllKeychain(local) local.updatedAt = cloudDate try? context.save() } else if cloudDate < local.updatedAt { await pushToMockCloud(local) }
Fig. 25-11
flowchart TD
    A[App launch: load local profile] --> B{GET /user/settings}
    B -->|404: cloud has nothing| C[Push local up to cloud]
    B -->|network error, LAN unreachable| D[Stay local-only, retry next launch]
    B -->|found, cloud.updatedAt greater than local| E[Apply cloud values, re-write Keychain, bump local updatedAt]
    B -->|found, cloud.updatedAt less than local| F[Push local up to cloud]

Four cases:

  • Cloud has nothing for this user. Push local up. This is the fresh-install case: the iOS app starts with whatever survived in Keychain (everything iCloud-synced fields by default; the seven sensitive fields use kSecAttrSynchronizable=true), and the mock cloud gets seeded as the user’s first launch on the LAN.
  • Cloud unreachable. Silent. The next save will try the PUT again; the next launch will try the GET. No retry queue; just opportunism.
  • Cloud is newer. Apply, re-write into Keychain (so iCloud Keychain carries the merged value to other devices that haven’t reconciled with the mock cloud yet), bump local updatedAt to match. The “re-write into Keychain” step is the load-bearing part of cross-device sync; without it, two devices would only converge when they both made it home to the LAN.
  • Local is newer. Push up. Most common case for a power user: edit a setting offline (calibrate while on the road), get home, app launches, push happens before any read could overwrite it.

How it actually behaved on first boot

After deployment, I checked what was on disk:

$ ssh m2 'cat ~/mock-cloud/logs/user_settings/21E70CDA-...JSON'{ "email": "", "unitSystem": "imperial", "version": 1, "updatedAt": "20260518T044304.095000Z", "gender": "MALE", "weightCalibrationFactor": 0.9990998620664758, "name": "<my-name>", "dateOfBirth": "<my-dob>", "heightCm": 175.88, "healthKitEnabled": true, "serverReceivedAt": "20260518T044304.134233Z"}

40ms between client updatedAt and server serverReceivedAt: clock skew across the LAN is well under a second, which means the timestamp-based merge will Just Work. The file appearing at all (with no manual PUT on my part, no settings screen tap, no opt-in dialog) means the auto-reconcile path fired on the next normal app launch, found the cloud empty for this user-key, and seeded it. Exactly the behavior you’d want from a backup that you almost never have to think about.

What this is, and what it isn’t

It’s not a multi-device sync product. It’s a local backup of the dozen-or-so fields that would otherwise live in iCloud Keychain alone, written to a server I control, on hardware I own, by way of a UUID that I rotate when I want to and that nobody else has. The motivation isn’t redundancy theater: it’s “I keep wanting to bounce between debug and TestFlight builds without thinking about whether my calibration factor is still 1.0 underneath.”

That’s also why I didn’t reach for CloudKit (the existing dead stub in ProfileStore). CloudKit would require an entitlement, a real DEVELOPMENT_TEAM, and a CKContainer setup process that’s overkill for “save 12 fields somewhere durable.” The mock cloud is right there, has the auth I already use for every other endpoint, and the implementation cost was a Python afternoon.

§ 25.3

What I’d watch next

Two things to keep an eye on:

  1. Anchored-query observer behavior across iOS lifecycle states. I register the observer in foreground only; the next time someone weighs themselves on an Apple Watch while the app is closed, that sample won’t enter the SwiftData cache until the user reopens the app and the launch-time anchored fetch picks up the delta. Functionally fine, but if I ever want push-driven freshness, enableBackgroundDelivery(for:frequency:) is the next step and it adds a real entitlement.

  2. Conflict semantics on two-device-edit. Both devices edit a setting while off-LAN, both come home, both push at nearly the same time: last write wins. Probably fine for me as sole user. If this ever becomes a real product, last-writer-wins is the wrong policy for some fields (calibration factor in particular: you don’t want the older calibration overwriting the newer one if both happened to be edited). Worth a structured merge per field, eventually.

For now the boring useful thing got built: HealthKit gets fed every scan, and the settings live in three places: local memory, iCloud Keychain, and my mirror box.

§ 25.4
§ 25.5
Agent prompts Procedure 25-A as prompts for a coding agent · 5 prompts

Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.

1
Discover real HealthKit identifiers
Before writing any HealthKit integration code, find the actual supported body-composition HKQuantityTypeIdentifier values by grepping the SDK header directly: grep -E "HKQuantityTypeIdentifier(Body|Waist|Height|Lean)" on HealthKit.framework/Headers/HKTypeIdentifiers.h inside the installed Xcode.app. Map the app's existing measurements onto only the six identifiers that exist: write bodyMass (kilograms) and bodyFatPercentage (as a 0-1 fraction, so divide the app's stored 0-100 value by 100) and waistCircumference (meters) directly from scan data; derive bodyMassIndex (weight / height_m^2, stored with the count unit per Apple's convention), leanBodyMass (weight * (1 - bodyFat/100)), and height from the existing user profile. Do not add write paths for hip, chest, thigh, calf, or upper-arm circumference as HealthKit quantity types since none exist, and leave those ten measurements as app-only data.
2
Dedup HealthKit writes with sync identifiers
In the HealthKit write path, make every HKQuantitySample saved for a scan carry metadata [HKMetadataKeySyncIdentifier: "nm-\(scan.id)-<metric>", HKMetadataKeySyncVersion: 1], where <metric> is a stable per-type string such as "weight", "bodyFat", or "waist". Apply this to both call sites that can write a sample for the same scan: the summary-only insertion that fires the instant scanState == .complete (weight + body-fat, no zip yet) and the later upgrade write that re-saves the same scan once rings.json is mounted (adds waist etc). Confirm that calling store.save(sample) twice with the same syncIdentifier replaces the HealthKit entry in place rather than inserting a duplicate, so the chart never double-counts a single scan.
3
Anchored import plus observer delta reads
Implement HealthKit import in two phases for each of the three readable types (weight, body-fat, waist). Phase one: on first enabling the HealthKit toggle, run a single HKAnchoredObjectQuery with anchor: nil and limit: HKObjectQueryNoLimit to pull all historical samples, then serialize the returned HKQueryAnchor with NSKeyedArchiver.archivedData(withRootObject:requiringSecureCoding: true) and store it in UserDefaults under a per-type key such as anchorKey(for: kind). Phase two: register an HKObserverQuery per type; in its callback, run a new HKAnchoredObjectQuery seeded with the saved anchor to fetch only the delta, insert the new samples into the local HealthSample SwiftData cache, and persist the updated anchor the same way. Make sure the saved anchor is read back on next launch instead of re-running the full bulk import every time.
4
Filter self-written samples from overlay
When mapping HealthKit query results into the imported-history overlay, skip any sample where qs.sourceRevision.source.bundleIdentifier == Bundle.main.bundleIdentifier, since those are the app's own writes already represented by the Scan-backed foreground series, and including them would double-count every scan in the chart. Then add a second LineMark series to the existing SwiftUI Charts history view with series: .value("Source", "Health"), a translucent foregroundStyle, and a dashed StrokeStyle(lineWidth: 1, dash: [3, 2]), so the Health-sourced line renders as its own connected series instead of zig-zagging with the scan series in date order. Only add this overlay for the three metrics that exist in HealthKit (weight, body fat, waist); leave the hip/chest/thigh tabs scan-only since there is nothing to overlay.
5
Add /user/settings backup endpoint
Add a /user/settings route to the mock cloud's Python HTTP handler supporting GET and PUT, scoped by an X-User-Key header. Validate the key against a strict allowlist regex (^[A-Za-z0-9_-]{1,128}$) before using it as a filename, rejecting anything else with 400. For GET: return 400 if the header is missing, 404 if no file exists yet for that key (so the client can distinguish "new user, nothing to merge" from an error), else 200 with the stored JSON. For PUT: parse the JSON body, stamp it with a server-side serverReceivedAt timestamp, write it to disk under the sanitized key, and return 200 with the stored payload. On the iOS client, in ProfileStore.load(), after Keychain hydration completes, fire a reconciliation task: on 404 push the local profile up; on network error stay local-only and retry next launch; if the cloud's client-set updatedAt is newer than local, apply the cloud values and re-write them into Keychain; if local is newer, push local up. The client, not the server, must set updatedAt at edit time so last-writer-wins reconciliation is meaningful across devices.