feat(ios): let the Home Screen widgets refresh themselves - #558
Merged
Conversation
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".
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Widget freshness was "time since the app last ran a successful publish" — for someone who opens Maple twice a day, hours.
BGAppRefreshTaskwas 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_summaryreturns 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:
requiredScopeForRequestderives an API key's required scope from the first path segment, so a key scopedwidget_summary:readreaches exactly one endpoint. Composed from the generic endpoints, the same credential would neederror_issues:read+services:read+traces:read— an organization read key, sitting on a phone.BGAppRefreshTasknow 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 —
WidgetIssueTitleowns 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_keyskind"device", minted byPUT /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
ApiAuthorizationV2Layershaped this and are worth a reviewer's attention:kind !== "standard"rejected every non-standard key on /v2, so the non-adminmcpmint path was unusable. It is now anmcp-only rejection.roles: resolved.roles ?? apiKeyDefaultRolesdefaults toroot, andcreateonly writesmetadataJson.rolesformcpkeys. A naivestandardwidget key would have landed on every user's phone root-roled, fenced only by the scope regex.devicekeys 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_keysso 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 linkMapleAPI: it gets ~30MB and a few seconds, and the generated client is 30k lines plusOpenAPIRuntime.The fetch enriches a timeline and never gates one —
makeEntryruns first and would answer on its own, so a failure costs freshness and nothing else. Also:completeUntilFirstUserAuthentication— notUserDefaults(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 areloadAllTimelines— 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
mobile_devicessub-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 onidentifierForVendorunder its own family instead.refreshAfterstays at 45 minutes. The date in aTimelineReloadPolicyis 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
WidgetFetchStateinto the App Group andWidgetPublisherdrains it onto the nextwidget.refreshspan (outcome, consecutive failures, age of last success, credential-rejected).WidgetRefreshSchedulerand the BG task are deliberately kept for one release as a fallback while that telemetry proves the new path.Verified
bun typecheckandbun run lintclean across all 40 packagesswift testcases inMapleWidgetDataTestsbun run ios:openapi:checkup to dateNot 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
kindenum is a TypeScript-level constraint on a plaintextcolumn.touchLastUsedneeded no work — it already has a 5-minute memo plus a zero-row-UPDATEpredicate, so the new per-rebuild traffic will not amplify writes.organizationsToPublishwas removed, not kept:lastPublishedAtis 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.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.