initial port to iOS#42
Conversation
Live could show a bonded strap without actually re-arming the active BLE feed. The UI treated bonded like active connection state, so reconnects could leave realtime HR off even after BLE_REALTIME_HR_ON was acknowledged. This firmware also needs the R10/R11 realtime stream enabled before it emits usable live BPM. Re-arm notifications on reconnect, scope R10/R11 to the Live tab, and let realtime raw frames update the visible HR. The Data Sources page had two file importers competing on the same screen, so WHOOP export selection could route through the wrong importer state. Use one target-aware importer instead. Co-authored-by: Kabir M Khalil <kabiru02@outlook.com>
- Add a "Strap support" section: WHOOP 4.0 is the tested/supported path; 5.0/MG live HR works (confirmed on hardware), deeper metrics experimental with an opt-in toggle. Frames the project honestly as experimental. - Add the Readiness feature, the optional AI Coach (honestly: opt-in, BYO key, the one network feature, sandbox-blocked on macOS), and the experimental 5/MG toggle. - Note the onboarding expectations flow + the in-app What's New changelog, and link CHANGELOG.md.
Closes the long-open licensing question. The project documents itself as independent and non-commercial, so a permissive or copyleft OSI licence (which all permit commercial use) would have contradicted that posture. PolyForm Noncommercial 1.0.0 is a proper, expert-drafted software licence (patent terms included) that matches the project's intent: free for personal and other non-commercial use — read, run, fork, and contribute — but no commercial grant. - Add LICENSE (PolyForm Noncommercial 1.0.0) with a preamble scoping it to NOOP's own original code, noting protocol facts are uncopyrightable, and that bundled deps keep their own licences. - Add NOTICE listing the MIT dependencies (GRDB.swift, ZIPFoundation) and the community reverse-engineering NOOP's protocol facts come from (my-whoop, goose). - README: licence badge + a "License" section explaining it's source-available, not OSI "open source" (which would permit the commercial use the project rules out). - CONTRIBUTING: inbound = outbound (PRs licensed under the same terms). - DISCLAIMER §4: reference the licence and the non-commercial scope. - Correct "open-source" → "community" wherever it described the unlicensed upstream work, across the docs and the two in-app credit lines, so nothing implies an OSI licence the project doesn't carry.
…oopApp#15) The macOS app is ad-hoc signed but not notarized (notarization needs a paid Apple Developer ID tied to a real identity, which doesn't fit an anonymous free project), so Gatekeeper blocks the quarantined download and users saw "damaged"/"unverified". Document the one-time fix: xattr -dr com.apple.quarantine, or System Settings → Privacy & Security → Open Anyway (right-click → Open on macOS 14 and earlier).
Closing the app used to tear down the BLE connection: WhoopBleClient was owned by the Activity-scoped AppViewModel, and onCleared() disconnected and shut it down. People reported the strap dropping the moment they closed NOOP. - Move the BLE client + repository to a process-wide singleton (NoopApplication) so the connection outlives any single Activity/ViewModel. - Add WhoopConnectionService: a foreground service (connectedDevice type) that holds the process up with a quiet ongoing notification mirroring live state. - New "Keep connected in the background" toggle in Settings -> Strap (default on); AppViewModel starts/stops the service on connect/disconnect and on toggle change. - onCleared() no longer disconnects when background mode is on, and never calls shutdown() (the client is process-owned now). - Declare the previously-missing POST_NOTIFICATIONS permission (the runtime request in MainActivity was a no-op without it) and the service in the manifest. - Failure-isolate every foreground/notification call so a platform quirk can never crash the connect flow. On macOS this already came for free: AppModel is an app-level @StateObject kept alive by the menu-bar extra, so closing the window keeps streaming. Bump to 1.3; add the release to the in-app What's New (both platforms) and CHANGELOG.
People on Reddit reported that after connecting, live heart rate gets stuck on a stale value while the strap stays "connected" — the only way to un-stick it is a manual disconnect/reconnect. Root cause: the WHOOP firmware lets its realtime stream lapse unless it's periodically re-armed, and a CCCD can silently drop. The macOS BLEManager already handles this with a 30s keep-alive; the Android client had no equivalent (its only timers were for historical offload). WhoopBleClient: - Add a 30s keep-alive (port of BLEManager.keepAliveFire), started on the WHOOP4 connect handshake and on the WHOOP5/MG bonded-on-HR transition. Each tick, on the live path (never during offload): re-subscribe a dropped notification if the stream has gone quiet, re-arm TOGGLE_REALTIME_HR (WHOOP4, while the Live screen wants it), poll battery (~60s, also keeps the link warm), and — if NOTHING has arrived for 120s — bounce the link so the auto-rescan re-bonds and resumes streaming (the automatic version of the manual disconnect/reconnect). - Track lastDataAtMs on every inbound notification to feed the liveness watchdog. - Own wantsRealtime via start/stopRealtime() (AppViewModel routes the Live screen's start/stopRealtimeHr through them) so the keep-alive only re-arms when HR is wanted. - The bounce is gated on !backfilling and always re-posts the cadence, so it can never abort a long historical offload or orphan the keep-alive after a reconnect. - Mark intentionalDisconnect @volatile (read on the GATT binder thread). Framing.Reassembler: - Reject an impossible frame length (>8 KB; the largest real WHOOP frame is ~1920 B) and resync to the next SOF, instead of waiting forever for bytes that can't arrive — a corrupt packet used to wedge the live stream until a reconnect. - Add reset(), called on each (re)connect (matches the macOS per-connect fresh Reassembler), so a partial frame from one session can't corrupt the next. - Tests cover both: garbage-length resync and reset-drops-partial. Bump to 1.4; What's New + CHANGELOG updated on both platforms.
Returning the full 8-element tuple of inline `try Int.fetchOne(...) ?? 0` expressions could make Swift's type-checker time out on some toolchains/machines (reported by a contributor building the macOS app locally). Binding each count to its own `let` first is behaviour-identical and trivial to type-check. No schema or logic change; WhoopStore tests (testStorageStats, testInsertReturnsRowCounts) green. Reported by an external contributor building the macOS app locally.
…hang (NoopApp#17) A 5/MG strap requires an encrypted (bonded) link before it will allow subscribing to its characteristics — it rejected the puffin notify chars with "Authentication is insufficient", so the CLIENT_HELLO handshake waited forever for notifications that never came and the UI hung at "Finishing the secure pairing handshake…". Diagnosed from a shared strap log by @artmzrbn on issue NoopApp#17. - Write CLIENT_HELLO with .withResponse (was .withoutResponse) so CoreBluetooth runs just-works bonding and didWriteValueFor fires. - Add a .whoop5 branch in didWriteValueFor: on the bond ack, mark the link established and (re)subscribe the puffin notify chars + standard HR/battery; do NOT run the WHOOP4 command handshake (a 5/MG strap rejects WHOOP4-framed commands). - Store the puffin notify chars at discovery so they can be re-subscribed post-bond. - Skip the keep-alive's WHOOP4 command pings on a 5/MG link (dropped anyway; keeps the log clean). Strictly isolated to the experimental 5/MG path (gated on deviceFamily == .whoop5); WHOOP 4.0 connect/bond/offload is byte-for-byte unchanged (verified by review). Unverified on real 5/MG hardware — shipping experimental for the reporters to confirm. Bump to 1.5.
…nfusion) A Reddit tester saw the alarm log print "…+0000" and reasonably concluded the alarm was being set in UTC and would be wrong for non-UTC timezones. It isn't: the wake time is built with Calendar.current (the user's LOCAL zone) and SET_ALARM_TIME carries the absolute instant of that local time, so the strap fires at the correct local moment regardless of how its UTC RTC is labelled. The only thing that was "+0000" was Date's default UTC description in the debug log. Log the wake time formatted in the user's local zone (with the zone abbreviation) and note the epoch is UTC, so the log no longer reads like a timezone bug. No functional change to the alarm — deliberately NOT applying a timezone offset, which would break the currently-correct firing time.
… default People on Android couldn't share what their strap was doing — log() only went to logcat (adb-only), so connection issues (NoopApp#17 on Android, NoopApp#18) were undiagnosable. WhoopBleClient now keeps an in-memory ring buffer of the log; Settings → Strap → "Share strap log" writes it to a cache file and opens the share sheet (FileProvider, applicationId-scoped authority). Also fix the LiveState `worn` default: it was `false`, so the UI showed "Worn: Off" forever when no WRIST_ON event arrived (NoopApp#18). Now defaults `true`, matching the macOS LiveState. Bump to 1.6 (also carries the macOS alarm-log-in-local-time clarity from the prior commit).
…, NoopApp#20) Cherry-picked from @j0b-dev's PRs NoopApp#19 and NoopApp#20 — reviewed, build-verified, and reimplemented onto current main as NoopApp. Both are safe-by-design (additive, opt-in/off-by-default, read-only on the strap, WHOOP 4.0 untouched) and directly serve the 5/MG decode effort. macOS frame capture (NoopApp#20): - PuffinCapture (pure, in WhoopProtocol, unit-tested) + PuffinFrameRecorder (@mainactor glue). - "Record puffin frames" toggle under Settings -> Experimental, with Export / Reveal. Records the 5/MG puffin frames already received (stamped with wall-clock + live HR) to JSON in App Support. - BLEManager wires capture into the whoop5NotifyChars path only (additive, gated). The author couldn't compile this on macOS; verified here -- the app build SUCCEEDED, @mainactor isolation checks out, the WHOOP 4.0 path is byte-for-byte unchanged. Linux workbench + whoop-decode (NoopApp#19): - whoop-decode executable on WhoopProtocol (Foundation-only; decodes captures with the SAME shipped decoder, no second decoder to drift). Package.swift adds the product/target; the library is untouched. - tools/linux-capture: bleak capture + a stdlib framing module with tests + a bonding probe. Only the curated non-destructive command set is sent; no network/shell/exec; capture files git-ignored. - docs: hardware-verified WHOOP 5.0 bonding/session notes that CONFIRM the v1.5 just-works-bond approach (CLIENT_HELLO with-response triggers the bond). Clarified the doc to tie that to the macOS v1.5 fix + issue NoopApp#17, and softened a stale "unverified command set" comment. Verified: WhoopProtocol 86 tests pass, whoop-decode links, Python framing tests (14) pass, macOS app + Android release builds green. 3-lens adversarial review (safe-for-verified-path on all three). Bump to 1.7. The on-strap 5/MG capture flow still wants a real hardware test (asked the reporters).
Cherry-picked from @j0b-dev's PR NoopApp#21 — reviewed, build-verified, reimplemented as NoopApp. parseFrameWhoop5 previously left the inner record raw; it now decodes the live REALTIME_DATA (type 40) record by reusing the existing 4.0 schema shifted by +4 (the 5.0 inner record starts at byte 8 vs byte 4 on 4.0), so WHOOP 5 reports live HR + R-R from the puffin channel too. Hardware-verified by the contributor: HR at inner-offset 16 matched the standard 2A37 profile to ~0.4 bpm mean |delta| across 96 worn-strap frames; R-R 572-637 ms. Confined to parseFrameWhoop5 — WHOOP 4.0 decode is byte-for-byte unchanged (the PR ships a testWhoop4RealtimeIsUnaffected test that asserts exactly that). Other 5.0 packet types keep their inner record raw pending per-type verification before the +4 rule is applied. Verified here: WhoopProtocol full suite 89 tests pass (86 existing incl WHOOP4 + 3 new), build green.
…pp#17, NoopApp#18) macOS (NoopApp#17): the Live screen's strap-log card now has Copy / Save... buttons (NSPasteboard + NSSavePanel) so Mac users can attach the connection log to a bug report — Android had this since 1.6, Mac didn't, and the stuck 5/MG reporters are on Mac. Android (NoopApp#18): the Health Monitor HR chart derived its series from R-R intervals, which are sparse on WHOOP 4.0, so it fell back to a flat 2-point line even while HR was changing. Now it plots a rolling buffer of the live HR over time (additive UI state; falls back to R-R then the flat pair while the buffer fills). No change to other screens. Bump to 1.8. Both platforms build green.
…read (PR NoopApp#22) Cherry-picked from @Brechard's PR NoopApp#22 — reviewed (2-lens adversarial: no regression to the verified WHOOP 4.0 path; all v1.4-v1.8 additions verified intact), build + unit tests green, reimplemented as NoopApp. A bonded WHOOP 4.0 could show NO live data — HR/battery/worn/events all blank — even though it connected, bonded, and accepted commands (buzz worked). Root cause: connectGatt was called without a Handler, so Android delivered GATT callbacks on arbitrary binder threads. onServicesDiscovered then raced a concurrent callback that drained the CCCD queue to empty and fired the bond's with-response write first; the bond held the stack's single GATT slot, so every writeDescriptor (subscribe) returned BUSY and — with no retry — was abandoned. Not one notification ever enabled. Hardware-verified on a Pixel 8 / Android 16 with before/after logs. - connectGatt now passes the main-looper Handler so all callbacks serialise on one thread. - drainCccdQueue / drainWriteQueue re-post to the main looper if entered off-thread. - a transiently-BUSY CCCD write retries (bounded, short backoff) instead of dropping the stream. Refinements on review (no-regression): gated the handler overload to API 28+ (the stack only reliably honours callback-thread affinity from Android 9, which is also where the race reproduces; 26/27 keep unchanged behaviour — no regression, no main-thread decode on old devices); and replenish the shared BUSY-retry budget on each successful subscribe so one stalled char can't starve the others. Bump to 1.9.
… HR on across HR screens (NoopApp#18) NoopApp#17 (WHOOP 5/MG, from a Pixel 8 Pro log: CLIENT_HELLO written, then nothing): the Android path wrote CLIENT_HELLO with WRITE_TYPE_NO_RESPONSE, which never triggered the strap's just-works bond, so it sat connected-but-unbonded and silent (a 5/MG strap won't even stream standard 0x2A37 HR on an unauthenticated link). CLIENT_HELLO is now a confirmed (with-response) write that holds the slot and, on the ACK in onCharacteristicWrite, marks the link bonded (+ fires the opt-in puffin probe post-bond). Mirrors the macOS v1.5 fix; strictly gated to connectedFamily == WHOOP5 — WHOOP 4.0 untouched. NoopApp#18 (Health Monitor froze when opened from Live; log showed TOGGLE_REALTIME_HR=00 on Live-leave): leaving Live stopped the realtime HR stream outright, so Health Monitor got no updates. The realtime stream is now REF-COUNTED in AppViewModel (requestRealtimeHr/releaseRealtimeHr) — it stays on while ANY live-HR screen is visible, and runConnectHandshake arms it on bond if wanted, so handing off Live <-> Health Monitor never drops it. Both screens use a DisposableEffect request/release. Bump to 1.10. Build + unit tests green.
The dashboard anchored "today", the 14-day sparklines and the Trends W/M/3M windows to the newest stored ROW (macOS: latestDay) rather than the device's actual calendar date. After a historical WHOOP import that meant a months-old day rendered as today's recovery/readiness, and the trend windows showed the last N imported days instead of the last N calendar days (issue NoopApp#23, reported by @Brechard; corroborated by Vikedlol seeing April data as "today"). Fix — date-anchor to the real local day key (ISO yyyy-MM-dd, compares chronologically), on BOTH platforms: Android - AppViewModel: _today = days.lastOrNull { it.day == LocalDate.now() } - TodayScreen.remember14: last 14 CALENDAR days (cutoff filter), drop the takeLast(14) + "else all" fallback that resurrected old imports - TrendsScreen.windowValues: last N calendar days, not takeLast(n) rows macOS (parity) - Repository.today: days.last(where day == localDayKey(now)); +week to last 7 calendar days; new Repository.localDayKey(_:) (cached POSIX fmt) - TodayView.trailingWindow: cutoff-key filter - TrendsView.days(for:): cutoff-key filter, no longer anchored to latestDay / repo.days.last No-op for the common case (recent contiguous data: last-N-days == last-N-rows) — only stale-import dashboards change: Today/sparklines go empty for the real today and Trends auto-widen to where the data is, so older imports stay visible under the wider ranges / All history. No regression for anyone on current data.
macOS WHOOP 5/MG (issue NoopApp#17) — reimplemented from a 5/MG owner's hardware-verified flow (dmisgano99); the v1.5 attempt bonded but never streamed on real hardware. All changes scoped to the .whoop5 path; WHOOP 4.0 untouched. - BLEManager.didDiscoverCharacteristicsFor: retain the puffin notify chars (fd4b0003/4/5/7) but DO NOT subscribe pre-bond — on an unauthenticated link the strap rejects them ("Authentication is insufficient"), which wedged the bond. Removed the wrongly-timed pre-bond PuffinExperiment realtime probe. - didWriteValueFor: subscribe the puffin chars only after the CLIENT_HELLO .withResponse write confirms; then arm realtime HR with puffin framing (send(.toggleRealtimeHR) once, guarded by whoop5RealtimeArmed). - send(): route the realtime-HR toggle through puffinCommandFrame for .whoop5 (the guard previously dropped every 5/MG command, so a bonded strap never started streaming); all other commands still dropped (no verified puffin equivalent). - Surface pairing-mode guidance (new LiveState.pairingHint, shown in SettingsView) when the bond is refused with "Encryption/Authentication is insufficient" — CoreBluetooth won't bond against a strap still paired to the official WHOOP app, so it must be in pairing mode. Readiness anchoring (issue NoopApp#23/NoopApp#24, caught via @Brechard's PR) — v1.11 anchored Today/sparklines/Trends to the real calendar day but left the Readiness card reading sorted.last, so a stale import still drove the "Should you push today?" read. Both platforms: - TodayView.swift / TodayScreen.kt: pass the local day key into ReadinessEngine.evaluate(...). - ReadinessEngine (Swift + Kotlin): when an explicit `today` is given but no row matches, return INSUFFICIENT instead of falling back to the newest stored row; today == nil still falls back (no regression for live-strap callers). Added a regression test on both platforms. No-op for the common case (recent contiguous data). Both platforms build green; StrandAnalytics + Android unit tests pass.
Decode the WHOOP 5.0 ("puffin") historical DSP biometric store. Decode
half only — no app-side offload wiring yet (that's a separate change,
gated behind an opt-in flag) — so this is a no-op for current users and
the WHOOP 4.0 path is untouched.
Reimplemented as NoopApp from @j0b-dev's PR NoopApp#25, which is hardware-
verified: the test vectors are real frames captured from a worn WHOOP 5
(2026-06-08) and the decoded values are cross-checked by physiological
invariants independent of the decoder (|gravity| ≈ 1 g, 60000/mean(R-R)
≈ HR, rr_count == #valid intervals) plus a 96/96 HR match against the
ground-truth 0x2A37 profile.
WhoopProtocol/Interpreter.swift:
- decodeWhoop5Historical: type-47 v18 record — version@9 (gated; any
other version falls back to a labelled raw region, nothing invented),
unix@15, heart_rate@22, rr_count@23, rr@24+2i, gravity f32@45/49/53.
Optical channels past +57 left as one honest raw region.
- decodeWhoop5Metadata: type-49 chunk unix@11 / subsec@15 /
trim_cursor@21, so classifyHistoricalMeta can drive the ack.
- whoop5HistoricalAckFrame: HISTORICAL_DATA_RESULT(23) = [0x01]+end_data
as a puffin command — the WHOOP 5 image of BLEManager.ackHistoricalChunk
and the byte-for-byte twin of the Python build_history_ack.
- readF32 helper; two new else-if branches in parseFrameWhoop5.
Offload unlock (docs §5): each HISTORY_END's end_data must be echoed via
HISTORICAL_DATA_RESULT(23) to advance the strap's trim cursor — without
it the cursor stays frozen and zero type-47 records are served.
tools/linux-capture: --history-ack / --history-only / --stop-on-idle
capture flow + ack queue + the HISTORY_END decode helpers. Added an
off-hot-path periodic flush (every 2s, between bursts) so a long capture
isn't all-or-nothing on a crash — the notify callback still never blocks.
Verified: 98 WhoopProtocol tests pass (9 new real-frame vectors incl.
ack-byte match + metadata classification; existing Whoop4/Whoop5 realtime
unaffected); 19 Python frame tests pass; macOS app builds.
Android brought a 5/MG strap to "Bonded — Streaming" (v1.10) but showed no HR: it only listened on the standard 0x2A37 profile, which a 5/MG strap doesn't stream. Realtime HR rides the puffin notify chars as REALTIME_DATA, exactly as on macOS. Brings Android to parity; all changes scoped to the .whoop5 path — WHOOP 4.0 byte-for-byte unaffected (NoopApp#17/NoopApp#26). protocol/Framing.kt - Reassembler is now family-aware: WHOOP5/MG framing is declLen @[2..4] / total +8 vs WHOOP4 length @[1..3] / +4. The WHOOP4 rule decoded a bogus ~6 KB length for a 5/MG frame and never emitted — the core reason no frames reached the decoder. - parseWhoop5 now decodes REALTIME_DATA at the +4 offsets (timestamp@10, heart_rate@16, rr_count@17, rr@18+), mirroring the hardware-verified macOS decode (PR NoopApp#21). Other 5/MG types stay envelope-only. ble/WhoopBleClient.kt - WHOOP5_NOTIFY_CHARS (fd4b0003/4/5/7) subscribed AFTER the CLIENT_HELLO bond (rejected on an unauthenticated link); routed through the family-aware reassembler in onInbound. - reassembler reassigned per connection with the detected family. - send(): routes TOGGLE_REALTIME_HR through puffinCommandFrame for .whoop5 (every 5/MG command was dropped before); others still dropped (no verified puffin equivalent). startRealtime/stopRealtime + the post-bond branch arm it. - Removed the now-dead opt-in puffin probe (realtime HR is armed by default post-bond, matching macOS v1.12). Verified: FramingTest decodes a real worn-strap REALTIME_DATA frame (HR=98, R-R=[603,587], ts=1780916382) and asserts the family-aware reassembler emits it where the WHOOP4 one can't. Full Android release + all unit tests pass; macOS universal builds. Known gap: 5/MG battery poll + haptic buzz still need their own verified puffin framing and remain dropped, so buzz won't fire on 5/MG yet (NoopApp#28).
After a historical import, Android Today rendered missing current-day
metrics as raw dashes and the recovery ring as a depleted 0%, which read
like broken/zero data rather than "no score for today yet."
ui/TodayScreen.kt
- Missing current-day metric tiles now show an explicit "No Data"
(NO_DATA) instead of "—"; gated on the value being null, so a user
WITH today's data is unchanged (values render normally).
- TodayRecoveryRing: when today's recovery is null, show "No Data" +
supporting text instead of a 0% / depleted ring.
- Added a Mac-style Today footer (TodayWorkoutsSection /
TodaySourcesSection): recent 14-day workouts when present + Data
Sources counts, so imported history is clearly labelled as history.
ui/TrendsScreen.kt
- Comment-only: corrected stale calendar-day-anchoring notes; the
accepted v1.12 Trends widening behaviour is unchanged.
No-op for the common case (today's data present). Android-only; brings
Today to parity with the Mac screen and completes the stale-import
cleanup from v1.11/v1.12. Full Android release + unit tests pass; macOS
universal builds.
Reimplemented as NOOP from @Brechard's PR NoopApp#31 (refs NoopApp#23) — reviewed and
authored here rather than merged.
@jamartif confirming live HR on v1.13 proved a 5/MG strap acts on our puffin-framed commands (the realtime-HR toggle reached it and started the stream). So the haptic buzz (RUN_HAPTICS_PATTERN) is now allowlisted through the same puffinCommandFrame transport in send(), on both platforms — powering Test buzz, the smart alarm, and any haptic feedback on 5/MG. - Strand/BLE/BLEManager.swift: 5/MG send() guard now allows .toggleRealtimeHR OR .runHapticsPattern. - android/.../WhoopBleClient.kt: same — TOGGLE_REALTIME_HR OR RUN_HAPTICS_PATTERN. Still experimental: whether the strap honours that specific command is the unverified part, but the transport is proven and the worst case is a no-op (no link teardown was seen with the HR toggle). Every other command stays dropped for 5/MG (the offload set needs its own verified framing). Battery already worked on 5/MG via the standard 0x2A19 profile, so it needed nothing here. WHOOP 4.0 is unaffected (issue NoopApp#28). Both platforms build; Android unit tests pass; macOS universal builds.
Decode the WHOOP 5.0 ("puffin") COMMAND_RESPONSE (type 36) replies,
reimplemented as NOOP from @j0b-dev's PR NoopApp#32. Decode-only — like the
historical decoder, it's a no-op until the app actually sends those
commands to a 5/MG strap (send() still drops GET_HELLO/GET_DATA_RANGE/
GET_BATTERY for whoop5), so no behaviour change ships here. WHOOP 4.0 is
untouched (gated on the whoop5 command_response post-hook).
WhoopProtocol/Interpreter.swift — decodeWhoop5CommandResponse:
- resp_cmd at frame[10] (4.0 frame[6] + 4); payloads diverge from 4.0,
so every field is mapped from a real capture (fw 50.38.1.0).
- GET_BATTERY_LEVEL: battery_pct = pay[2] DIRECT percent (the 4.0
deci-percent ÷10 is gone on 5.0).
- GET_DATA_RANGE: history_oldest/newest from the unix-range u32s
(bounded 2020..2027) → the offload window.
- GET_HELLO (145): device_name (printable ASCII @pay[16]) + fw_version
(@pay[93], guarded on the "5.0" generation byte). The same response
carries a SESSION TOKEN which the decoder NEVER reads or exposes.
Privacy: the token-bearing GET_HELLO test fixture is SYNTHETIC (fake name
"WHOOP-FAKE01", version bytes at real offsets, token region zeroed) — no
real device name or token enters a committed fixture; the battery fixture
is a traced token-free frame. No capture .json is committed.
Verified: 102 WhoopProtocol tests pass (4 new Whoop5CommandResponseTests:
battery, data-range, hello name+fw, guard-fails-closed) + the parity test;
WHOOP 4.0 decode unaffected. Also ports the --commands capture flag + the
response analyzer to tools/linux-capture.
Next (separate, not here): wire the sends now that the puffin transport is
hardware-proven — GET_HELLO would surface the strap's firmware + name on
5/MG; GET_DATA_RANGE feeds the held offload work.
Tooling-only (the Linux capture workbench) — no app or decoder change. Reimplemented as NOOP from @j0b-dev's PR NoopApp#33. - --commands now works for WHOOP 4: a 4.0 (CRC8) probe of the read-only GETs (battery, clock, version, ext-battery, data-range, hello), so a WHOOP 4 is testable the same way the 5 is. Command numbers verified against the authoritative WhoopCommand enum (Commands.swift: reportVersionInfo=7, getExtendedBatteryInfo=98, etc.) and are the same GETs NOOP already sends each connect — genuinely read-only (confirmed on real hardware: cmd 7 returned the 41.17.6.0 version block). - scan-first connect: resolve --address via BleakScanner before BleakClient, with a clear "wake the strap" hint on miss — BlueZ connects far more reliably to a freshly-discovered device than a bare address. - family-aware per-type tally: read the inner-type byte at offset 4 for WHOOP 4 vs 8 for WHOOP 5 (the summary was reading the 5.0 offset). Captures stay gitignored; no device data committed. Python tests green (19).
…NoopApp#34) Android: HealthConnectImporter stored its daily aggregates (steps/HR/HRV/ sleep/weight) under the shared "apple-health" deviceId — the same bucket the Apple Health export uses — so the Data Sources screen counted them under the Apple Health card. Only the workouts were correctly tagged source="health-connect". - HealthConnectImporter: writes ALL its data (AppleDaily + workouts) under its own "health-connect" deviceId, device named "Health Connect" (the recovery/sleep backfill still merges under "my-whoop"). Dropped the now-unused APPLE constant. - DataSourcesScreen: the Health Connect card now shows its own imported counts; the Apple Health card keeps "apple-health". - Today footer + WorkoutsScreen: union "apple-health" + "health-connect" for the unified external-health view, so nothing disappears. (CompareScreen unaffected — HC writes no metricSeries.) - One-time refile (WhoopDao + WhoopRepository.refileLegacyHealthConnect, called at HealthConnectImporter.import() start): moves legacy HC data out of "apple-health". HC workouts move by their source tag; the daily rows move only when there's no Apple Health EXPORT (no apple-health metricSeries, since only the export writes those). Safe + idempotent: runs before the import writes any HC data (no PK conflict), and post-fix nothing writes HC data to apple-health again. So re-importing refiles cleanly instead of duplicating. No data was ever lost — labelling only. Android build + unit tests pass; macOS universal builds (version bump only).
…firm (NoopApp#39) Both reimplemented as NOOP from @j0b-dev's PRs; both verified against real captured frames, additive, and WHOOP 4.0 decode unaffected. NoopApp#38 — WHOOP 5 EVENT (type 48): simple events (wrist on/off, double-tap, boot, pairing, BLE up/down, bonded) already decode via the +4 static walk. decodeWhoop5Event adds the one payload with on-device ground truth — BATTERY_LEVEL (soc@21 / mv@25 / charge@30 = 4.0 +4, KEEPS the 4.0 deci-percent ÷10, unlike the 5.0 COMMAND_RESPONSE direct percent; monotonic discharge 49.9→47.7% confirmed). Drift-safe: event names come ONLY from the shared EventNumber schema — an unnamed firmware number (e.g. 123) stays raw 0x7B(123), never borrowing CommandNumber's name (123 = SELECT_WRIST). Other event payloads left raw (no 5.0 ground truth). Whoop5EventTests: battery, double-tap, unknown-stays-raw. NoopApp#39 — WHOOP 4 historical-offload mode for the Linux capture tool (was whoop5-only) + a regression fixture confirming the firmware-drift question the WHOOP 5 v18 record raised: a real WHOOP 4 STILL emits v24 (Whoop4HistoricalV24HardwareTests: HR 109, R-R [555,564], |g|≈1, NOT drifted). tools/linux-capture gains family-aware capture + the 4.0 offload helpers (meta_type@6, trim_cursor@17, end_data frame[17:25], CRC8 acks). Shared command numbers (22/23) reused; only framing differs. Verified: WhoopProtocol tests pass (new Whoop5Event + Whoop4HistoricalV24 vectors + existing decode unaffected); 24 Python frame tests pass.
Reimplemented as NOOP from @eltomato89's PR NoopApp#37. Makes every macOS UI string translatable via a SwiftUI String Catalog, with the exact current wording preserved as the English (US) base — so adding a language is a translation task, not a code change. English-base only: NO user-visible change, no language shipped yet. macOS-only; Android + the shared Swift packages' runtime behaviour are unaffected. - Strand/Resources/Localizable.xcstrings (new): the String Catalog, 432 English (US) base entries (sourceLanguage: en). - project.yml: developmentLanguage: en + SWIFT_EMIT_LOC_STRINGS: YES (Xcode auto-extracts every LocalizedStringKey into the catalog on build). [Reconciled against the current 1.16 project.yml — the only hunk that didn't apply, since version bumps had moved its context.] - StrandDesign components (SectionHeader, the metric/stat rows, ScreenScaffold, InsightCard, the pill/overline, StatePill): the user-facing String params become LocalizedStringKey so SwiftUI localizes literals/interpolations automatically. The `.uppercased()` calls were dropped because `strandOverline()` already applies `.textCase(.uppercase)` — verified, so no casing regression. - 14 view files: 1-5 line opt-ins to pass LocalizedStringKey through. - Tools/seed-string-catalog.py + Tools/translate-de.py: dev utilities (seed the catalog; a static EN->DE translation map). No network/API, not part of the build — a German tool, not an enabled language. Reviewed: macOS BUILD SUCCEEDED with it fully applied (so no String-var call site broke against the new LocalizedStringKey params); uppercase preserved; no contributor identity in any committed file (anonymity); no network/build-time execution in the scripts.
Two macOS reporters wore a WHOOP 4 overnight (connected) and got "1 day,
0 sleeps." Root cause: sleep is staged from the strap's overnight GRAVITY/
motion stream (SleepStager.detectSleep returns [] on empty gravity), and
the WHOOP 4 historical (type-47) post-hook bailed out of decode ENTIRELY
for any version outside the schema's {12, 24} — no HR, no R-R, NO gravity.
So the offload "completed" (acks + HISTORY_COMPLETE) but stored no motion;
HR was backfilled from the realtime stream (no gravity); IntelligenceEngine
computed a day with HR but zero sleeps.
PostHooks.swift (historical_data): for an unmapped version, fall back to
the canonical v24 DSP layout (firmware overwhelmingly shares it — schema
notes V12 == V24) and ACCEPT only if physically valid: |gravity| ≈ 1 g
(the DSP gravity is a unit vector) AND a plausible HR. A wrong layout
yields random f32 gravity nowhere near 1 g → rejected, record left raw.
Mapped versions unchanged.
Backfiller.swift + BLEManager.swift: diagnostic — surface each unmapped
historical version once in the strap log (now only fires when the v24
fallback was REJECTED, i.e. a genuinely-different layout we must map).
Tests: accept (v24 record relabeled to v25 still decodes HR/gravity) +
reject (relabeled with wiped gravity → no biometrics stored). 111
WhoopProtocol tests pass; macOS universal builds; Android version bump
only (its Kotlin decoder is unaffected — the reporters are on Mac).
) + HC resilience (NoopApp#34) NoopApp#40 (macOS, @robin-liquidium): importing an Apple Health export overwrote the WHOOP import's status message in Data Sources. AppModel had ONE shared `importing`/`importSummary`, both importers wrote it, and only the WHOOP card rendered the summary — so an Apple Health import flipped both buttons to loading and replaced the WHOOP message in the WHOOP section, looking like a data overwrite. The data was always separate (my-whoop vs apple-health). Split the state per source (whoopImporting/appleImporting + per-source summaries); the Apple Health card now shows its own status. NoopApp#34 (Android, @spasypaddy): a single Health Connect record type failing ("count must not be less than 1, currently 0" on some devices/SDK builds) aborted the whole import — every readAll ran inside one try/catch. Each type's read is now self-contained: on failure it's logged + skipped, and every other type still imports (reads accumulate into shared buckets, so a partial type is simply absent, never corrupt). Both platforms build; Android unit tests pass.
Brings in v1.65 (dropped-chunk surfacing), v1.66 (Android WHOOP 4 fallback), v1.67 (manual workout tracking), v1.68 (sleep figures, HR zones, charging, calibration, illness notifications), and v1.69 (Live status cleanup + frame diagnostic). Resolves the Localizable.xcstrings conflict by merging the two string catalogs key-by-key — keeps upstream's translated values for shared keys, preserves the iOS-only-extraction additions from 748f393, and applies Xcode's 1.1 "key" : value format throughout. project.yml's macOS-only NOOPAppIntents exclusion (from 748f393) and the CompareView ViewThatFits fallback (from 0522c21) are untouched by the merge. Verified with an iOS Simulator build (iPhone 17, iOS 26.5).
iOS build extracted 7 new keys carried in by v1.67/v1.68 (manual workout flow, WHOOP 5/MG alarm caveat, %% display). Same shape as the catalog refresh that produced 748f393 — keeps the iOS-only extraction state in sync with the merged sources.
- Android Live screen shows "Syncing your strap history…" plainly during the offload, so sync visibility isn't just the brief "· syncing" pill suffix (NoopApp#91 / NoopApp#93). - macOS CompareView range controls use ViewThatFits to stack instead of overflowing on a narrow window (ported from the iOS port's fix).
Brings in v1.70 (clearer Android sync status + a smaller version of the iOS port's responsive Compare fix). The CompareView conflict resolves to HEAD's version: the iOS fork already had a stronger fix that wraps the stacked range pills in a horizontal ScrollView, so the pills can't clip on a narrow iPhone screen — upstream only ported the bare ViewThatFits half of it. Comment kept the iOS-fork wording since this is the iOS port, not a port of it. All other v1.70 changes (CHANGELOG, AppChangelog 1.70 release entry, MARKETING_VERSION 1.70 / build 56) merged cleanly. No new user-visible strings, so Localizable.xcstrings is untouched.
|
This is a real piece of work — a clean iOS port that shares the Swift core via I want to be honest rather than leave it hanging — two things:
So I'm going to hold this rather than close it, while I think through the iOS question. None of that reflects on the quality of the port — it's good. Genuinely appreciate the time you put in. |
|
Quick follow-up to keep this from going quiet: the position from my last note still stands — I'm holding PR #42 open, not closing it, while I think the iOS question through properly. It's a genuinely good port and I don't want it to disappear. To make sure nobody's blocked in the meantime: the iOS build already has a working home. @eltomato89 is publishing unsigned IPAs from their fork — https://github.com/eltomato89/noop/releases — so if you want to try it on a device today, that's the place (and the right place to file iOS-specific issues like the Two things I'm weighing, honestly, so the reasoning isn't a black box:
None of that is a knock on the work — @tigercraft4's review caught a handful of fixable things (WAL checkpoint before the iOS DB copy, UTC vs |
…ap item iOS has no anonymous distribution path — App Store and TestFlight both require a real Apple Developer identity — which is fundamentally at odds with NOOP staying anonymous. So the iOS port (#42) is build-from-source only, not officially maintained or distributed. Reframe both README platform rows from "planned/on the roadmap" to "experimental community port — build it yourself", linking PR #42 so people can find it.
Bring the remaining docs in line with reality and the README fix (4e2a3e9). BUILD.md, CONTRIBUTING.md, FEATURES.md and IOS.md no longer call iOS and Android "planned": Android ships as a full app (android/, APKs in Releases), and iOS is an experimental, build-from-source community port (#42). The iOS framing notes it is not officially maintained or distributed — iOS has no anonymous distribution path (App Store and TestFlight both require a real Apple Developer identity), at odds with the project staying anonymous. IOS.md gains a top-of-file note saying the same; BUILD.md's "Android (planned)" section and CONTRIBUTING.md's roadmap are reframed to match. macOS remains the reference implementation throughout.
|
Thanks for the heads up! I do not have a problem with integrating the pr under an anonymous identity! I (for myself) do consider an iOS app much more helpful then a macos app as it can connect to HealthKit wich is (more or less) the most useful thing to do when you have an iPhone and a Fitness Strap. So, if maintaining 3 platforms is to much, I would consider dropping the macos support in favor of the iOS app. P.S.: Regarding the document picker: I did not have any problems loading the whoop data export zip-file.. |
Upstream rewrote history (each prior version has a new SHA), so the
merge base resets back to v1.2 and the visible diff is large; only the
v1.71–v1.80 content is logically new. Brings in:
- v1.71/v1.72: Android GPS workouts + crash fix
- v1.73/v1.74: reconnect guide for 5/MG firmware-reset bond resets
(Mac then Android) + the Galaxy-S24 startup-crash fix
- v1.75: personal VitalBands baselines + macOS analytics parity
(steps, respiration, calories, skin-temp, schema v11)
- v1.76: tolerant Apple-Health import, marginal-radio HR fallback,
live HR graph time-series
- v1.77: first-run terms acknowledgment gate (TERMS.md), Explore
chart hover-flicker fix
- v1.78: daytime false-sleep guard + Android Sync-now button
- v1.79: manual workouts + edit/relabel/dismiss + CSV export
- v1.80: native journal logging + Imperial/Metric units toggle
Conflict resolution:
- Android files (kotlin + AndroidManifest + gradle + docs/ANDROID.md):
iOS fork never carried Android divergence (those commits in our
first-parent walk are pre-existing upstream content), so taken
straight from upstream.
- Pure upstream Swift additions (Database/WhoopStore schema v11,
LiveState reconnect/standard-HR fields, IntelligenceEngine
skin-temp + resp baselines, OnboardingWizard/TodayView unit
formatters): taken from upstream.
- iOS-port Swift conflicts resolved by hand:
* AppleHealthView: kept HEAD's LocalizedStringKey wrap (1fbcada).
* CompareView: kept HEAD's ScrollView fallback (0522c21 — same
conflict as the v1.70 merge).
* BLEManager: pure upstream additions (marginal-radio detector +
reconnect-guide clear). #if os(iOS) blocks auto-merged.
* LiveView: kept HEAD's PlatformPasteboard/FileExport abstractions
and #if os(iOS) strap-log header.
* SettingsView: kept HEAD's #if os(macOS) wraps around
revealPuffinCaptures + AppKit; took upstream's UnitPrefs/CSV-
export wiring; AppKit import now conditional on os(macOS).
* project.yml: restored HEAD's NOOPiOS/NOOPiOSWidgets targets
(upstream had dropped them), applied the 1.80/build 57 bumps
and the SWIFT_EMIT_LOC_STRINGS dedup.
- Localizable.xcstrings: HEAD's catalog is a strict superset of
upstream's (444 shared keys, identical German values; 30 HEAD-only
iOS-extraction keys; 0 upstream-only), so taken from HEAD. New
English strings are auto-extracted at build time.
Three post-merge fixes for the iOS build:
- CsvExport.swift wraps `import AppKit` in canImport and its
NSSavePanel block in #if os(macOS), with the DataBackup-pattern
DocumentPicker fallback on iOS.
- LiveView.swift: AppKit import moved under #if os(macOS) (upstream
had added it unconditionally for the now-replaced NSPasteboard
path).
- TermsGateView.swift: .toggleStyle(.checkbox) replaced with .switch
on iOS (checkbox is macOS-only).
Verified with an iOS Simulator build (iPhone 17, iOS 26.5).
Xcode 16 on macos-15 only carries the iOS 18 SDK, so any iOS 26-only symbol the app targets fails to compile in CI even though the local iOS 26.5 simulator build is green. macos-26 ships Xcode 26+ with the matching iOS 26 SDK; `latest-stable` lets the runner pick the newest shipping Xcode without pinning to a version that may be removed later. Addresses PR NoopApp#42 review (tigercraft4, .github/workflows/release.yaml:21).
Make explicit in the workflow that stripping entitlements removes the App Group and HealthKit capabilities from the unsigned build, so CI cannot validate WidgetSnapshot / PendingIntents / Live Activity cross-process paths. A green CI run only means the code compiles — those integration paths still need an Xcode build with the real entitlements. Addresses PR NoopApp#42 review (tigercraft4, .github/workflows/release.yaml:36).
Switch from -target NOOPiOS to -scheme NOOPiOS. Targeting bypasses scheme pre-actions, the dependency graph, and extension embedding, so the NOOPiOSWidgets / Live Activity extension would never compile and a broken widget target wouldn't fail CI. The scheme builds the whole app the way a real archive would. Addresses PR NoopApp#42 review (tigercraft4, .github/workflows/release.yaml:30).
Replace the hardcoded \`<string>25</string>\` in StrandiOS/Resources/Info.plist with \$(CURRENT_PROJECT_VERSION), and move the iOS build number onto the NOOPiOS target's settings.base.CURRENT_PROJECT_VERSION in project.yml. App Store Connect / TestFlight uploads now track one project setting instead of two literals that could drift apart and collide on upload. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Resources/Info.plist:23).
The iOS restoration option was duplicated verbatim across both BLEManager init overloads — easy to drift if one is updated without the other. Pull the CBCentralManager constructor into a single private static makeCentral(delegate:) so the restoration policy lives in one place. Adds a comment on the helper documenting CoreBluetooth's hard requirement: CBCentralManager must be constructed synchronously during app launch for state restoration to fire. AppModel constructs BLEManager synchronously, and StrandiOSApp.init constructs AppModel synchronously, so the eager-construction contract is already met. Addresses PR NoopApp#42 review (tigercraft4, Strand/BLE/BLEManager.swift:243).
\`WidgetSnapshot.suiteName\` (\"group.com.noopapp.noop\") must exactly match the \`com.apple.security.application-groups\` entitlement on BOTH the app and the widget target. A mismatch silently fails: \`UserDefaults(suiteName:)\` returns nil and every consumer (PendingIntents.append/drain, WidgetSnapshot.publish, Live Activity) becomes a no-op, with no log line or visible failure — only the widget showing nothing. Add \`assertGroupProvisioned()\` so the canary trips on the first debug-build run after a misprovisioning. Release builds compile it out — App Store apps can't crash on a missing entitlement. The call site (StrandiOSApp.init) is added in a follow-up commit so this one stays focused on the shared API. Addresses PR NoopApp#42 review (tigercraft4, StrandiOSShared/WidgetSnapshot.swift:27).
WhoopStore runs in WAL mode (PRAGMA journal_mode = WAL), so while the store is open recent writes live in <db>-wal, not the main .sqlite. The macOS export path calls copySidecarsIfPresent when the checkpoint closure couldn't run, so nothing on disk is lost even if the WAL wasn't folded in. iOS can't follow suit: DocumentPicker.export only hands the system a single file, so the -wal/-shm siblings can't ride along, and the import side would never see the pending writes. Guard on \`checkpointed\` and return a user-facing .failure when it's false, so we never produce a silently-incomplete backup on iOS. The common case (store open, checkpoint succeeds) is unchanged. Addresses PR NoopApp#42 review (tigercraft4, Strand/Data/DataBackup.swift:93).
exportText's iOS path used \`try?\` on the temp write and then opened UIActivityViewController whether the file existed or not. A failed write — disk full, sandbox path miss — handed the share sheet a missing/empty URL, so the user got a broken export with no error. Check the write outcome and only present on success. Also pass a \`cleanup\` list to the share-sheet completion handler so files we staged ourselves in \`temporaryDirectory\` don't accumulate across runs. exportFile is the caller-owns-the-file path (Puffin captures, SQLite backups) — those files are NOT cleaned up. Addresses PR NoopApp#42 review (tigercraft4, Strand/System/FileExport.swift:35).
\`update\` is driven by \`model.live.\$heartRate\` — roughly once per heart-rate sample, so ~1 Hz while a session is live. Instantiating \`ActivityAuthorizationInfo()\` per call is needless system-bridge allocation. ActivityKit's auth status only changes via Settings, so caching for the controller's lifetime is safe. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Widgets/LiveActivityController.swift:18).
If two HR samples arrived close together while \`activity == nil\`, both entered the else branch. The first \`Activity.request\` hadn't completed (and assigned \`self.activity\`) when the second check ran, so both saw nil and both fired \`Activity.request\` — creating two simultaneous Live Activities. The 2-second throttle only protected the update path, not the start path. Add a synchronous \`isStarting\` flag set BEFORE \`Activity.request\` so the second update bails immediately. The flag clears once the request returns (success or throw). Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Widgets/LiveActivityController.swift:29).
DayAgg has no bodyMass field and the sync loop never aggregates weight. Keeping .bodyMass in quantityReadIds added it to the HealthKit permission dialog and surfaced a privacy ask we don't honour — noise for the user with no consumed benefit. Add a comment so the next addition to this list goes through DayAgg first. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:50).
The rest of the project keys days by UTC (Repository.compareDayParser, WhoopStore's dailyMetric primary key). dayFormatter used .current, so a user crossing time zones mapped the same physical day to different yyyy-MM-dd strings — duplicate / split daily rows on the next upsert. The write-back path used the same formatter, so a key computed in one zone could orphan the prior day's row in another. Pin the formatter to TimeZone(identifier: \"UTC\") so reads and writes agree with the store and with each other. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:247).
writeBack created fresh HKQuantitySample instances on every call and HealthKit assigns a new UUID per save, so repeated sync() runs (one per app-active) flooded Apple Health with duplicate resting HR, HRV, SpO2, and respiratory rate samples — HealthKit doesn't dedupe by value, only by UUID. Switch to a delete-then-write upsert. Every emitted sample carries a deterministic HKMetadataKeyExternalUUID of \"noop:<noopDeviceId>:<metric>:<day>\". Before saving the fresh batch, delete any of OUR prior samples (scoped to HKSource.default() so we never touch another app's data) that carry the same keys. Now a re-sync replaces values instead of stacking duplicates. Save errors are still swallowed in this commit; the follow-up wraps writeBack in proper do/catch and surfaces the failure so lastSync doesn't advance over a permanently-skipped window. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:148).
The previous \`try? await store.save(samples)\` swallowed every HealthKit save failure — auth revoked for a specific type, invalid sample, quota hit — and \`lastSync = Date()\` ran anyway. The next delta sync skipped the window so the data was permanently never written back. Make writeBack throw, wrap the call in sync() in real do/catch, and only advance lastSync on success. New @published lastError gives the UI a hook to surface the failure (Settings can show a sync-failed state alongside the existing syncing / auth pills) instead of going silent. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/Health/HealthKitBridge.swift:150).
Cold launch fired \`health.sync()\` in two places: the .task block on ContentView (line 33) and again from .onChange(of: scenePhase) when the scene transitioned to .active (line 47). HealthKitBridge.sync guards on \`auth == .authorized\` and \`!syncing\` so two calls were a no-op once auth was settled, but on the very first launch — with the auth dialog still in flight — they overlapped and amplified the duplicate-sample problem the write-back fix targets. Keep scenePhase.active as the single trigger: it covers cold launch AND every foreground return. Leave requestAuthorization in .task for now; the next commit moves that out too. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/App/StrandiOSApp.swift:33 — double sync).
requestAuthorization() ran in .task on first launch, so the system permission dialog appeared before the user had seen any in-app rationale — against Apple HIG and App Review guidance for HealthKit. The auth dialog also raced sync() in the same .task block; if the user took a moment to answer, sync() started against an unsettled auth state. Drop the .task block entirely. HealthKitBridge.sync guards on \`auth == .authorized\` so the scenePhase.active trigger is a safe no-op until authorization is granted from an explicit user action (e.g. an "Enable Apple Health" row in Settings or an onboarding step). The comment in scenePhase points the next reader at where to wire that in. Also calls WidgetSnapshot.assertGroupProvisioned() in init so a missing App Group entitlement trips on the first debug run instead of silently no-op'ing the widget / Live Activity flow. Addresses PR NoopApp#42 review (tigercraft4, StrandiOS/App/StrandiOSApp.swift:33 — auth at launch, and StrandiOSShared/WidgetSnapshot.swift:27 — App Group canary call site).
Port NOOP to iPhone (iOS app, HealthKit, WidgetKit, App Intents)
Summary
Brings the standalone, offline WHOOP-strap companion app to iPhone. The existing
macOS app stays untouched in behavior; the shared
Strand/sources are madecross-platform via
#if os(...)guards and reused by a new iOS app target plus awidget extension. The five SwiftPM packages already built for iOS and are reused
as-is.
Both targets build cleanly against the iOS 26.5 simulator:
NOOPiOS(app) → BUILD SUCCEEDEDNOOPiOSWidgets(widget + Live Activity) → BUILD SUCCEEDEDWhat's included
RootTabView(TabView with Today / Trends /Live / Sleep / More) replacing the macOS
NavigationSplitViewsidebar. Sharedscreens are reused unchanged where possible.
CBCentralManagerOptionRestoreIdentifierKey, background modebluetooth-central.HealthKitBridge): reads HR (avg/max), resting HR,HRV (SDNN), SpO₂, respiratory rate, steps, active/basal energy, VO₂max and sleep;
writes NOOP's resting HR / HRV / SpO₂ / respiratory rate back to Health.
Live Activity (Lock Screen + Dynamic Island), sharing data through App Group
group.com.noopapp.noop.MarkMomentIntent,BuzzStrapIntent, surfaced viaNOOPShortcuts.Cross-platform refactor
Shared
Strand/code was abstracted behind small platform shims so the samesources compile for macOS (AppKit) and iOS (UIKit):
Strand/System/Platform.swift—PlatformImage,Image(platformImage:),PlatformPasteboard,PlatformOpen.Strand/System/FileExport.swift—NSSavePanelon macOS,UIActivityViewControlleron iOS.Strand/System/DocumentPicker.swift— iOS document import/export wrappers.QRCode,MacActions,AutomationsView,SupportView,LiveView,SettingsView,DataBackupmade cross-platform; macOS-only behaviors guardedwith
#if os(macOS)(e.g. lock screen action, Reveal in Finder,onExitCommand).macOS-only files (
StrandApp,RootView,MenuBar/, notification settings,macOS Info.plist/entitlements/assets) are excluded from the iOS target.
New files
iOS app (
StrandiOS/)App/StrandiOSApp.swift,App/RootTabView.swift,App/AppModel+iOS.swiftHealth/HealthKitBridge.swiftSystem/NOOPAppIntents.swiftWidgets/WidgetPublish.swift,Widgets/LiveActivityController.swiftResources/Info.plist,Resources/NOOP.entitlements,Resources/Assets.xcassets/Shared with widget (
StrandiOSShared/)WidgetSnapshot.swift,LiveActivityAttributes.swiftWidget extension (
StrandiOSWidgets/)NOOPWidget.swift,NOOPLiveActivity.swift,NOOPWidgetBundle.swiftInfo.plist,NOOPWidgets.entitlementsProject / build config
project.yml: addedNOOPiOS(application, iOS 17.0 deployment target) andNOOPiOSWidgets(app-extension) targets, App Group + HealthKit entitlements,Info.plist keys (Live Activities, background BLE, HealthKit usage strings),
shared
StrandiOSSharedsources compiled into both targets.Testing
xcodegen generatexcodebuild -scheme NOOPiOS -destination 'platform=iOS Simulator' build→ SUCCEEDEDxcodebuild -scheme NOOPiOSWidgets -destination 'platform=iOS Simulator' build→ SUCCEEDEDNotes
CODE_SIGNING_ALLOWED=NO. Running on a physicaldevice requires setting
DEVELOPMENT_TEAMinproject.yml.