Tops

← Home · ~/tops · updated 1 month ago

Tops

A standalone iOS weather app: a Home/Lock-Screen widget ("Feels like" — the apparent temperature, wind, precip chance, and a clothing hint) backed by a minimal host app that owns location. Extracted from Riff on 2026-05-28 with zero Riff dependency — its own Xcode project, bundle IDs, App Group, location source, tests, and (eventually) deploy.

The widget can't ship alone (a WidgetKit extension needs a host app), so Tops ships a minimal real host app that (a) owns location — requesting Always authorization and running significant-location-change monitoring so it keeps the App Group coordinate fresh in the background (CoreLocation → App Group write + WidgetCenter reload, no foregrounding needed), and (b) shows current conditions plus "add the widget to your Home/Lock Screen" guidance.

Layout

tops/
  README.md                       this spec
  ios/
    .gitignore                    Tops.xcodeproj/, build/, DerivedData/, …
    project.yml                   XcodeGen manifest (Tops + TopsWidget + TopsTests)
    test.sh                       simulator unit-test runner (Swift Testing)
    Tops/                         HOST APP — owns location, shows conditions
      TopsApp.swift               @main App; init → LocationCache.bootstrap(); scenePhase → refresh()
      LocationCache.swift         CoreLocation (Always + SLC background) → App Group write + widget reload
      CurrentConditionsModel.swift  ObservableObject; fetch via OpenMeteoClient
      CurrentConditionsView.swift   minimal UI: conditions + widget guidance
      Info.plist                  GENERATED from project.yml (GENERATE_INFOPLIST_FILE: NO, but xcodegen synthesizes)
      Tops.entitlements           App Group group.mark.tops ONLY
      Assets.xcassets/            AppIcon (placeholder 1024² — real icon is follow-up)
    TopsWidget/                   the widget extension
      OpenMeteoClient.swift       Open-Meteo fetch + pure feels-like/severity helpers
      CoordCache.swift            App-Group coordinate contract + pure staleness decision (shared all 3 targets)
      WeatherProvider.swift       TimelineProvider: CoreLocation + Open-Meteo + App-Group fallback
      WeatherEntry.swift          TimelineEntry
      WeatherView.swift           SwiftUI views per family (medium/rect/inline/circular)
      WeatherWidget.swift         Widget definition + family list
      WeatherWidgetBundle.swift   @main WidgetBundle
      Info.plist                  GENERATED from project.yml
      TopsWidget.entitlements     App Group group.mark.tops
    TopsTests/
      WeatherLogicTests.swift     WMO-severity + Steadman feels-like (pure)
      LocationCacheTests.swift    App-Group key/suite contract round-trip
      CoordCacheTests.swift       pure coordinate staleness decision (7-day window)

The widget's Swift types keep their weather-domain names (WeatherProvider, WeatherEntry, WeatherView, WeatherWidget, WeatherReading, worstWeatherCode) — they model weather, not the app. Only the app/target/bundle identity is "Tops". weather_code / worst_weather_code are Open-Meteo JSON keys and must never be renamed.

Identity

Thing Value
Project / display name Tops
Host app bundle id mark.tops
Widget extension bundle id mark.tops.TopsWidget
Test bundle id mark.tops.tests
App Group group.mark.tops
Team 6C63UU27YB
Deployment target iOS 17.0
Widget-tap deep link tops://open (registered; launches the app, no v1 handler)

The App Group is group.mark.topsnever group.mark.riff (that one stays in Riff for unrelated features).

Weather data

Open-Meteo (TopsWidget/OpenMeteoClient.swift), imperial units, 5s timeout.

  • Feels-like is the Steadman shade model (apparentTempF), computed locally — chosen over the US NWS convention (which has a 50–80°F dead band where feels-like == air temp and wind is ignored) and over Open-Meteo's own apparent_temperature field (that adds a solar-radiation term that ran ~6°F cold). Plain Steadman is continuous across all temperatures and wind-sensitive, matching how Apple's "feels like" behaves directionally. Still not Apple-identical (Apple's formula is proprietary); a flat calibration offset can be added in apparentTempF if it lands consistently off in one direction.
  • Icon is the worst-of-day WMO code (worstWeatherCode) across today's local-calendar-day hourly forecast, falling back to the current code if the forecast fetch failed. The tier ranges in worstWeatherCode deliberately match the iconName switch so the surfaced icon and the severity ranking can never disagree.
  • forecast_days=1 + timezone=auto returns the full 24-slot local-day hourly arrays (anchored to local midnight); precip-prob is read at the current-hour index, not [0].

Location

The host's LocationCache keeps the App Group (lat, lng, ts) coordinate fresh in the background so the widget never loses Mark's location while the app is closed. It requests Always authorization (two-step: When-In-Use first, then an upgrade prompt to Always) and runs startMonitoringSignificantLocationChanges() — the lowest-power location API that survives app termination and background-wakes Tops on ~500m movement. On every fix (foreground OR background SLC wake) it writes (lat, lng, ts) to UserDefaults(suiteName: "group.mark.tops") under keys lastKnownCoord.{lat,lng, ts} and calls WidgetCenter.shared.reloadAllTimelines()no foregrounding required. The foreground one-shot requestLocation() path is kept as belt-and-suspenders.

Auth is two-step (iOS can't grant Always in one prompt): LocationCache.refresh() requests When-In-Use, and once that's granted promoteToAlwaysIfPossible() surfaces the Always upgrade prompt once (guarded so it never spams). If the user grants only When-In-Use the app still works via foreground capture — it degrades to the old "refresh on open" behavior and never crashes or re-prompts. SLC background relaunch requires Always; under When-In-Use the widget still benefits from the 7-day coordinate window below. startBackgroundMonitoring() sets allowsBackgroundLocationUpdates = true only once authorization is Always AND the location background mode is present (setting it otherwise throws NSInternalInconsistencyException). On a background SLC relaunch, TopsApp.init touches LocationCache.shared.bootstrap() to construct the manager + wire the delegate before the queued fix is delivered (no scene is active).

The widget reads the coordinate as tier 3 of its fallback chain:

  1. Fresh CoreLocation fix (requestLocation, 3s timeout) — the widget process is short-lived but Apple permits CL here.
  2. Cached CoreLocation fix (CLLocationManager().location).
  3. App Group UserDefaults coordinate (< 7 days old) — written by the host.

If all three fail the widget renders "Tap to grant location". The tier-3 coordinate freshness was extended 24h → 7 days: a stale-but-correct coordinate (Mark stationary at home for days) beats collapsing to "no location"; the cutoff stays finite so a genuinely abandoned install eventually re-prompts. This is the COORDINATE-age window only — the separate 4h WEATHER-reading staleness (WeatherProvider.staleThreshold) and the .stale entry state are unrelated and unchanged.

iOS requires NSLocationWhenInUseUsageDescription (both plists) plus, on the host only, NSLocationAlwaysAndWhenInUseUsageDescription and UIBackgroundModes: [location]. The widget plist is unchanged — the widget never does background location; it only reads tier 3.

The host's CurrentConditionsModel resolves a coordinate the same way (a fresh CLLocationManager().location fix, else the tier-3 App Group read) and calls OpenMeteoClient.fetch. The host does not import the widget extension (extensions aren't importable); instead OpenMeteoClient.swift is compiled into all three targets — widget, host, and test bundle — since it's Foundation-only (no @main, no WidgetKit). The new TopsWidget/CoordCache.swift is shared the same way: it holds the App-Group coordinate contract (suite + key names) plus the pure staleness/resolution decision (CoordCache.resolve), so both the host and the widget call one source of truth and the decision is unit-testable without the CoreLocation delegate. That keeps the host free of any WidgetKit dependency.

Signing & first install

Status — 2026-06-14: signing is fully headless via the App Store Connect API key. ios/tops-ota.sh mints and signs over plain SSH using the ASC API key (ASC_KEY_ID / ASC_ISSUER_ID in ~/.env + the .p8 in ~/.appstoreconnect/private_keys/), so it no longer depends on the GUI-managed profile cache. That cache is not persistent — Xcode prunes a project's managed profiles when you don't open it for a while; when mark.tops dropped out, the old -allowProvisioningUpdates-only flow failed with No Accounts / No profiles for 'mark.tops' were found, because the CLI cannot reach the GUI session's AuthKit account (not even via launchctl asusersu spawns a fresh security session that severs it). The API key sidesteps the account entirely and mints straight from Apple's portal. Install page is live at https://marks-mac-mini.tail20af9f.ts.net/tops-ota/install.html — see Deploy. The manual Xcode-GUI steps below are now only a fallback / historical record.

With the ASC API key wired into tops-ota.sh, App-ID registration and profile minting both happen headlessly over SSH — no manual Xcode step is required. The manual GUI procedure below is kept as a fallback (e.g. if the API key is ever revoked). The simulator test suite (./test.sh) is green independent of all of this — signing is skipped on simulator destinations.

  1. Register two Explicit App IDs at developer.apple.com → Certificates, IDs & Profiles → Identifiers → (+) → App IDs → App (team 6C63UU27YB):
  2. mark.tops
  3. mark.tops.TopsWidget On both, enable the App Groups capability.
  4. Register the App Group group.mark.tops under Identifiers → App Groups → (+), then associate it with both App IDs (edit each App ID's App Groups capability → check group.mark.tops).
  5. One-time Xcode-GUI profile mint:
  6. cd ~/tops/ios && xcodegen generate
  7. open Tops.xcodeproj
  8. For each target (Tops, then TopsWidget): Signing & Capabilities → set Team 6C63UU27YB → confirm "Xcode Managed Profile" appears and the App Group group.mark.tops is listed.
  9. If the Signing tab alone doesn't provision, set the run destination to Any iOS Device (a ⌘B against a Simulator destination does NOTHING for device signing) and build once.
  10. Profiles cache to ~/Library/Developer/Xcode/UserData/Provisioning Profiles/; after this, headless device builds + a future tops-ota sign fine.
  11. First device install via Xcode (the profile-minting build above doubles as the first install): with an iPhone connected / on the CoreDevice tunnel, Run the Tops scheme to the device. Add the widget to the Home/Lock Screen from the widget gallery ("Feels like") and verify it renders on device.

Testing

cd ios && ./test.sh                 # all tests, default sim (iPhone 17 Pro)
SIM='iPhone 17' ./test.sh           # override the simulator

Swift Testing on the simulator; no formatter dependency (raw xcodebuild). Green only when it exits 0 and prints ** TEST SUCCEEDED **. Editor squiggles ("No such module …") are false positives — trust the command, not SourceKit.

Covered: - WeatherLogicTests — the WMO-severity table (worstWeatherCode) and Steadman feels-like reference points + monotonicity properties (apparentTempF). Pure, deterministic, no network/CoreLocation/App-Group side effects. - LocationCacheTests — locks the App-Group suite name + key names the widget's tier-3 fallback depends on (group.mark.tops, lastKnownCoord.{lat,lng,ts}) via an isolated UserDefaults round-trip. Red if the suite or keys drift. - CoordCacheTests — pins the pure coordinate staleness decision (CoordCache.resolve): a coordinate ~2 days old is ACCEPTED under the 7-day window (the red→green for the persistent-location change — fails under the old 24h constant), one >7 days old is REJECTED (finite-cutoff guarantee), a (0,0) coordinate is rejected, a fresh one is accepted, and staleCoordThreshold >= 7*24*60*60. Also re-asserts the App-Group contract literals on the shared CoordCache type. Deterministic via an injected now (no clock dependency).

Limits (same as Riff documented for the identical code): - No CoreLocation-delegate unit testLocationCache.refresh() / startMonitoringSignificantLocationChanges() trigger the real OS permission + background-wake machinery, which can't run deterministically in the unit-test sandbox. The coordinate staleness decision is now unit-tested via the extracted pure CoordCache.resolve; the App-Group round-trip via LocationCacheTests. The CL delegate / SLC background relaunch itself is validated on device after the OTA install. - The widget itself can't be rendered in the simulator (no reliable widget gallery for custom extensions). Its rendering is validated on device after the signing handoff above.

Deploy

Cable-free over-the-air installs run through ios/tops-ota.sh:

KEYCHAIN_PW='<mac-login-password>' ~/tops/ios/tops-ota.sh

It signs headlessly via the App Store Connect API key (ASC_KEY_ID / ASC_ISSUER_ID read from ~/.env, the .p8 auto-discovered in ~/.appstoreconnect/private_keys/), so -allowProvisioningUpdates mints the mark.tops / mark.tops.TopsWidget profiles with no Xcode account in the loop. It unlocks the login keychain for codesign (headless over SSH — reference_riff_codesign_keychain_ssh), xcodegen generates, archives Debug, exports a development-signed .ipa via the committed ExportOptions-adhoc.plist (method development, team 6C63UU27YB, signingStyle automatic), and publishes the .ipa + an itms-services manifest.plist + a one-tap install.html to ~/www/tops-ota/. It prints the public install-page URL: https://marks-mac-mini.tail20af9f.ts.net/tops-ota/install.html — Mark opens that in Safari (off-LAN, over cellular) and taps Install Tops. No cable, no devicectl (the headless Mini can never see the phone on USB).

KEYCHAIN_PW is the Mac login password, passed inline on the invocation only — it is never written into the script (which is committed to git). From an already-unlocked GUI Terminal it can be omitted (the unlock is a no-op there).

tops-ota is far simpler than Riff's: Tops has no shared secret and no host endpoint (no .xcconfig, no configFiles:), so there is no secret-less verify gate. To cut a new build, bump CFBundleVersion (host + widget in lockstep — see below) and re-run the script.

CFBundleVersion lockstep: the host and widget CFBundleVersion must always move together (both start at 1); a mismatch breaks the archive/OTA — the same invariant Riff's OTA enforces. The persistent-background-location change bumps both host + widget 1 → 2 in lockstep; the bump is made in project.yml info.properties for BOTH targets, then xcodegen generate regenerates the committed Tops/Info.plist + TopsWidget/Info.plist (so the tops-ota.sh plutil read sees 2). All plist edits — usage strings, UIBackgroundModes, CFBundleVersion — go through project.yml, never the generated plists directly (xcodegen silently reverts hand edits on the next generate).

Provenance

Extracted from ~/agents/riff/ios/RiffWidget/ (the 6 widget sources) + ~/agents/riff/ios/Riff/LocationCache.swift on 2026-05-28, then renamed Weather → Tops the same day. The App Group was renamed group.mark.riffgroup.mark.tops; the @main host app is TopsApp; the widget-tap URL was repointed riff://recordtops://open; and user-visible app-name strings in the no-location states became "Tops". The weather-domain logic (OpenMeteoClient.swift, WeatherProvider, WeatherEntry, WeatherView, WeatherWidget, WeatherWidgetBundle) is otherwise byte-for-byte the same as Riff's, and WeatherLogicTests carries over verbatim.

Follow-ups (not blocking v1): a real 1024² app icon (currently a placeholder); the tops-ota skill; a richer host UI (hourly/daily forecast) if wanted.