Tops

← Home · ~/tops · updated 5 days 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) 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.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 (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:

  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 (< 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.tops and …: mark.tops.TopsWidget, each carrying the group.mark.tops entitlement. Headless device builds and tops-ota now sign without the GUI. ios/tops-ota.sh is built (2026-05-28) and the install page is live at https://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.

  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.

Limits (same as Riff documented for the identical code): - No CoreLocation-delegate unit testLocationCache.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.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.