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.tops — never 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 ownapparent_temperaturefield (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 inapparentTempFif 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 inworstWeatherCodedeliberately match theiconNameswitch so the surfaced icon and the severity ranking can never disagree. forecast_days=1+timezone=autoreturns 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:
- Fresh CoreLocation fix (
requestLocation, 3s timeout) — the widget process is short-lived but Apple permits CL here. - Cached CoreLocation fix (
CLLocationManager().location). - App Group
UserDefaultscoordinate (< 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.shmints and signs over plain SSH using the ASC API key (ASC_KEY_ID/ASC_ISSUER_IDin~/.env+ the.p8in~/.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; whenmark.topsdropped out, the old-allowProvisioningUpdates-only flow failed withNo Accounts / No profiles for 'mark.tops' were found, because the CLI cannot reach the GUI session's AuthKit account (not even vialaunchctl asuser—suspawns 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 athttps://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.
- Register two Explicit App IDs at developer.apple.com → Certificates, IDs &
Profiles → Identifiers → (+) → App IDs → App (team
6C63UU27YB): mark.topsmark.tops.TopsWidgetOn both, enable the App Groups capability.- Register the App Group
group.mark.topsunder Identifiers → App Groups → (+), then associate it with both App IDs (edit each App ID's App Groups capability → checkgroup.mark.tops). - One-time Xcode-GUI profile mint:
cd ~/tops/ios && xcodegen generateopen Tops.xcodeproj- For each target (
Tops, thenTopsWidget): Signing & Capabilities → set Team6C63UU27YB→ confirm "Xcode Managed Profile" appears and the App Groupgroup.mark.topsis listed. - 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.
- Profiles cache to
~/Library/Developer/Xcode/UserData/Provisioning Profiles/; after this, headless device builds + a futuretops-otasign fine. - 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
Topsscheme 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 test — LocationCache.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.riff → group.mark.tops; the @main host app is TopsApp; the
widget-tap URL was repointed riff://record → tops://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.