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) requests When-In-Use location and runs the
CoreLocation → App Group write + WidgetCenter reload, 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; scenePhase → LocationCache.refresh()
LocationCache.swift CoreLocation → App Group write + widget reload
CurrentConditionsModel.swift ObservableObject; fetch via OpenMeteoClient
CurrentConditionsView.swift minimal UI: conditions + widget guidance
Info.plist hand-authored (GENERATE_INFOPLIST_FILE: NO)
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
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 hand-authored
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
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 (When-In-Use CoreLocation) writes (lat, lng, ts) to
the App Group UserDefaults(suiteName: "group.mark.tops") under keys
lastKnownCoord.{lat,lng,ts} on every foreground, then calls
WidgetCenter.shared.reloadAllTimelines(). The widget reads that 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 (< 24h old) — written by the host.
If all three fail the widget renders "Tap to grant location". iOS requires
NSLocationWhenInUseUsageDescription (set in both the host and widget plists).
The host's CurrentConditionsModel resolves a coordinate the same way (App Group
read + a fresh CLLocationManager().location fix) 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). That keeps the host free of any WidgetKit dependency.
Signing & first install
Status — 2026-05-28: signing minted. Both Xcode-managed Team Provisioning Profiles now exist and are cached in
~/Library/Developer/Xcode/UserData/Provisioning Profiles/:iOS Team Provisioning Profile: mark.topsand…: mark.tops.TopsWidget, each carrying thegroup.mark.topsentitlement. Headless device builds andtops-otanow sign without the GUI.ios/tops-ota.shis built (2026-05-28) and the install page is live athttps://marks-mac-mini.tail20af9f.ts.net/tops-ota/install.html— see Deploy. The first on-device install is now a Safari tap on that link, not a Run-from-Xcode step. The steps below are the procedure of record — and how to redo it if the profile cache is ever wiped.
New iOS App IDs cannot be created headlessly, and a new extension target
needs a one-time Xcode-GUI provisioning-profile mint (a -allowProvisioningUpdates
build over SSH can't reach the Apple-ID session). So the first Tops build
to a device is a manual Xcode step. 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.
Limits (same as Riff documented for the identical code):
- No CoreLocation-delegate unit test — LocationCache.refresh() triggers the
real OS permission machinery, which can't run deterministically in the unit-test
sandbox. The test targets the UserDefaults App-Group contract instead.
- 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
The first install was the Xcode-GUI step under Signing & first install (it
also minted the profile). After that, cable-free over-the-air installs run
through ios/tops-ota.sh (built 2026-05-28):
KEYCHAIN_PW='<mac-login-password>' ~/tops/ios/tops-ota.sh
It unlocks the login keychain (headless codesign 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.
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.