Skip to content

feat(ios): let the Home Screen widgets refresh themselves - #558

Merged
Makisuo merged 2 commits into
mainfrom
feat/ios-widgets-self-refresh
Aug 21, 2026
Merged

feat(ios): let the Home Screen widgets refresh themselves#558
Makisuo merged 2 commits into
mainfrom
feat/ios-widgets-self-refresh

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Why

Widget freshness was "time since the app last ran a successful publish" — for someone who opens Maple twice a day, hours. BGAppRefreshTask was not moving it; its own doc comment already conceded it is the floor, not the mechanism.

The thing that made this fixable: the widget was already getting its wake-ups. The timeline policy is .after(+1h), so the provider ran ~24×/day per pinned instance — and spent every one of those rebuilds re-reading the same stale bytes off disk. The fix is not more budget, it is using the rebuilds we already get.

Expected outcome: hours-stale → under an hour, for widgets people actually look at. Not live. A widget on page 3 that nobody views gets few rebuilds and no design changes that.

What changed

One request instead of four

GET /v2/widget_summary returns both widget surfaces in one payload, replacing the four the app used to compose (/v2/error_issues, /v2/services, two /v2/traces/timeseries).

The request count is the weakest reason for it. The two real ones:

  • It is the credential's fence. requiredScopeForRequest derives an API key's required scope from the first path segment, so a key scoped widget_summary:read reaches exactly one endpoint. Composed from the generic endpoints, the same credential would need error_issues:read + services:read + traces:read — an organization read key, sitting on a phone.
  • It fixes a path that already exists. A BGAppRefreshTask now has one round-trip to land inside its few seconds instead of four.

The wire carries bucket counts plus the bucket length (the client divides, so the sparkline and the headline provably share a unit) and the raw naming fields rather than a rendered title — WidgetIssueTitle owns the fallback and the app's issue list uses it too, so a title cannot resolve differently on the Home Screen than in the list.

The device credential

New api_keys kind "device", minted by PUT /v2/widget_credentials/{installation_id}. Read-only, 30 days, and every ceiling is the server's — the caller names an installation and nothing else. Roles are pinned from the minting session, so a credential on a phone can never outrank the human who asked for it.

Two blockers in ApiAuthorizationV2Layer shaped this and are worth a reviewer's attention:

  • kind !== "standard" rejected every non-standard key on /v2, so the non-admin mcp mint path was unusable. It is now an mcp-only rejection.
  • roles: resolved.roles ?? apiKeyDefaultRoles defaults to root, and create only writes metadataJson.roles for mcp keys. A naive standard widget key would have landed on every user's phone root-roled, fenced only by the scope regex. device keys always carry pinned roles, and one without them is rejected rather than promoted.

It cannot renew itself — the mint scope is not in its grant — so renewal always goes through a signed-in session. Revoked on sign-out and on leaving an org, locally and server-side. Kept out of GET /v2/api_keys so a dozen rows nobody created by hand don't land in front of an admin looking for the two they did.

The extension fetches

WidgetSummaryFetcher — hand-written, one endpoint, ~5s budget. The extension still does not link MapleAPI: it gets ~30MB and a few seconds, and the generated client is 30k lines plus OpenAPIRuntime.

The fetch enriches a timeline and never gates one — makeEntry runs first and would answer on its own, so a failure costs freshness and nothing else. Also:

  • Snapshots are written to the App Group before entries are built, so a provider killed on the way to rendering still leaves the data behind.
  • One fetch covers both widgets and coalesces, so three pinned instances woken together make one request. A stamp written before the request covers the app racing the extension.
  • 401/403 is terminal. A rolled credential answers 401 forever; retrying every rebuild would spend the day's budget on failures. Only the app lifts it.
  • The credential is a file in the App Group with completeUntilFirstUserAuthentication — not UserDefaults (a bearer token in cleartext in a backup) and not the app's keychain group, which is where Clerk keeps the session this design exists to keep out of the extension.

Silent push

apns-push-type: background, priority 5, collapsed per organization, riding the alert fan-out's existing per-tick/per-rule budget. On the device it is just a reloadAllTimelines — the widget fetches with its own credential.

It is a hint and nothing depends on it: iOS throttles these on an unpublished schedule, and it only reaches phones that accepted notifications at all.

Two deliberate deviations from the plan

  1. The mint route is not a mobile_devices sub-resource. That was the plan, and it was wrong: a device row is keyed on an APNs token, so it would have quietly meant "no widget refresh unless you also accept alerts" — two permissions that have nothing to do with each other. It keys on identifierForVendor under its own family instead.
  2. refreshAfter stays at 45 minutes. The date in a TimelineReloadPolicy is a floor iOS may ignore, so asking four times as often mainly produces a widget that has spent its allotment by mid-afternoon and goes cold in the evening — exactly when an on-call user cares. Repeated failures back off instead (15m → 30m → 1h → 4h).

Observability

The extension links no telemetry, so a fetch that fails there fails in complete silence — the exact shape of the bug this replaces. It records WidgetFetchState into the App Group and WidgetPublisher drains it onto the next widget.refresh span (outcome, consecutive failures, age of last success, credential-rejected).

WidgetRefreshScheduler and the BG task are deliberately kept for one release as a fallback while that telemetry proves the new path.

Verified

  • bun typecheck and bun run lint clean across all 40 packages
  • 182 v2 route tests, 1280 API service tests, 550 domain tests
  • 135 swift test cases in MapleWidgetDataTests
  • iOS app + widget extension build for the simulator
  • bun run ios:openapi:check up to date

Not verified — needs a signed device build and a live API: force-quit → rebuild fetches, airplane-mode fallback, revoke → 401 → re-mint, sign-out, pre-first-unlock Lock Screen. The silent-push path additionally needs APNs credentials.

Reviewer notes

  • No migration: the kind enum is a TypeScript-level constraint on a plain text column.
  • touchLastUsed needed no work — it already has a 5-minute memo plus a zero-row-UPDATE predicate, so the new per-rebuild traffic will not amplify writes.
  • The oldest-first ordering in organizationsToPublish was removed, not kept: lastPublishedAt is stamped by the app alone, and the extension now refreshes an org without touching it, so the sort key would rank a perfectly current org as the most starved one.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Widget freshness was "time since the app last ran a successful publish" —
for someone who opens Maple twice a day, hours. BGAppRefreshTask was not
moving it; its own doc comment already conceded it is the floor, not the
mechanism.

The thing that made this fixable: the widget was already getting its
wake-ups. The timeline policy is .after(+1h), so the provider ran ~24x a
day per instance and spent every one of those rebuilds re-reading the
same stale bytes off disk. The fix is not more budget, it is using the
rebuilds we already get.

GET /v2/widget_summary returns both widget surfaces in one payload,
replacing the four requests the app used to compose. The request count
is the weakest reason for it. The two real ones: it is the credential's
fence, because an API key's required scope is derived from the first
path segment, so widget_summary:read reaches exactly one endpoint where
error_issues+services+traces would have been an org read key on a phone;
and a BGAppRefreshTask now has one round-trip to land instead of four,
which raises the success rate of the mechanism that already existed.

The extension fetches with a device credential the app mints for it —
read-only, 30 days, scoped by the server, roles pinned from the minting
session so it can never outrank the human who asked for it. It cannot
renew itself, so renewal always goes through a signed-in session. New
api_keys kind "device", admitted on /v2 by turning the standard-only
check into an mcp-only rejection.

An incident additionally sends one silent content-available wake-up per
phone, collapsed per organization, riding the alert fan-out's existing
budget. It is a hint and nothing may depend on it: iOS throttles these
on an unpublished schedule, and it only reaches phones that accepted
notifications at all.

Two places this differs from what was planned:

- The mint route is not a mobile_devices sub-resource. A device row is
  keyed on an APNs token, so that would have quietly meant "no widget
  refresh unless you also accept alerts" — two permissions that have
  nothing to do with each other. It keys on identifierForVendor under
  its own family instead.
- refreshAfter is 45 minutes, not shortened. The date in a
  TimelineReloadPolicy is a floor iOS may ignore, so asking four times
  as often mainly produces a widget that has spent its allotment by
  mid-afternoon and goes cold in the evening. Repeated failures back
  off (15m, 30m, 1h, 4h) so a widget that cannot fix itself stops
  spending the day's rebuilds on 401s.

The fetch enriches a timeline and never gates one: makeEntry runs first
and would answer alone, so a failure costs freshness and nothing else.
Snapshots are written to the App Group before entries are built, one
fetch covers both widgets and coalesces across pinned instances, and a
401/403 is terminal until the app mints again. The extension links no
telemetry, so it records its outcome into the App Group and the app
drains it onto the next widget.refresh span — silent failure is the
exact bug this replaces.

Expected outcome: hours-stale becomes under an hour for widgets people
actually look at. Not live.
CI caught a real bug, not a test artifact: the widget extension would
have failed to decode every payload it fetched on iOS 18.

`JSONDecoder.DateDecodingStrategy.iso8601` is backed by
`ISO8601DateFormatter` with default options, which reject fractional
seconds. Every v2 timestamp is `toISOString()` output — milliseconds and
all — so `.iso8601` cannot read any of them.

It does not fail everywhere, which is what hid it. The Swift Foundation
rewrite in newer OS versions parses fractional seconds happily, so this
passed on a macOS 26 laptop and failed on a macOS 15 runner. The
deployment target is iOS 18, so it would have shipped as "the widgets
never refresh on most phones" with no error anywhere — an undecodable
payload is indistinguishable from a fetch that failed.

MapleAPI already knew about this trap and handles it in
`ResolvedTimeWindow.parse`, whose comment says so; the new module
reintroduced it. `WidgetJSON` now owns the coders for MapleWidgetData
and parses both shapes, matching that proven approach. Every store in
the module moves onto it — including the ones that only read what they
wrote, since a payload written by a different build should not be able
to break a widget either.

Keeps writing timestamps without fractional seconds: sub-second
precision means nothing to a surface whose smallest unit is "1m ago".
@Makisuo
Makisuo merged commit aacdfb9 into main Aug 21, 2026
34 checks passed
@Makisuo
Makisuo deleted the feat/ios-widgets-self-refresh branch August 21, 2026 10:37
@Makisuo
Makisuo deployed to pr-preview August 21, 2026 10:37 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit a375102 · View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant