feat: server-driven Dynamic Widgets - #271
Merged
V3RON merged 15 commits intoSep 9, 2026
Merged
Conversation
serverUpdate.url becomes optional so a widget can be marked server-driven with the URL supplied at runtime, and a widget that also has an entry gets the 15 minute default and floor that both platforms can honour.
Widgets rendered from fetched props need to tell fresh data from stale, so env.serverUpdate carries the outcome of the last fetch. WidgetServerUpdateSettings is the runtime twin of the serverUpdate key in app.json.
voltra.widget.server holds the settings stack both render engines read through: a scope type, four layers stacked in a fixed order, one resolver that owns the merge rule, the store that writes the runtime layers, and the request builder and fetcher that turn resolved settings into what a backend sees. The credential store and crypto manager move here, keeping their DataStore file and keys.
voltra.dynamicwidget.serverupdate fetches a JSON object, parses it as props, renders it once off screen, and only then commits it to the widget's existing props slot, so a response the widget cannot draw leaves the last good one on screen. Drawing itself is untouched: the worker asks Glance to re-render rather than pushing RemoteViews. The one addition to the render path is an environment seam that gives a server-driven widget env.serverUpdate.
…stack The URL and the interval move out of generated Kotlin literals and into assets/voltra/widget_server_defaults.json, so setWidgetServerUpdate can override them at runtime. The payload worker and refresh button now build their request with the same code the Dynamic engine uses, which gives them runtime URLs, non-GET methods and ETags too. A widget with both entry and serverUpdate gets a receiver that schedules Dynamic server updates.
An app can now change a server-driven widget's url, interval, method, query, headers and body at runtime, per widget or for all of them, and stop fetching altogether to drive a widget itself. Setting or clearing reschedules the widgets it affects and fetches once. The credential API keeps working as a wrapper over the same stored records.
shared/WidgetServer holds the settings stack both render engines read through: a scope type, four layers stacked in a fixed order, one resolver that owns the merge rule, the Keychain-backed store that writes the runtime layers, and the request builder and fetcher that turn resolved settings into what a backend sees. VoltraKeychainHelper moves here, keeping its service and accounts.
The payload fetcher keeps its API and its response contract, but builds its request from resolved settings rather than reading Info.plist and the Keychain itself. That gives payload widgets runtime URLs, non-GET methods, extra headers and query, and ETag revalidation; a 304 keeps the current content and stays on the normal interval instead of the failure retry.
A Dynamic Widget with a serverUpdate now gets a provider that wraps the plain one: it fetches a JSON object on every timeline request, parses it as props, renders it once off screen, commits it to the widget's existing props slot, and schedules the next entry from the resolved interval. Props that do not render are never committed, so a bad response leaves the last good one on screen. The only touch on the render path is env.serverUpdate, carried on the entry.
An app can now change a server-driven widget's url, interval, method, query, headers and body at runtime, per widget or for all of them, and stop fetching altogether to drive a widget itself. Setting or clearing reloads the widgets it affects. The credential API keeps working as a wrapper over the same Keychain records, and clearWidget now drops a widget's runtime settings with its data.
Adds the response contract, a PHP endpoint example, the env.serverUpdate fields and the runtime settings API to both platforms' server-driven widget pages, and marks the credential API deprecated there.
- Cache-Control: max-age and Retry-After were parsed and then discarded; both now move the next fetch, clamped, on either platform. - Payload widgets no longer send If-None-Match: their last response is kept only for one extension process on iOS and is cleared by clearWidget on Android, so a 304 could strand a widget on its placeholder. - clearWidgetServerCredentials no longer wipes the DataStore it now shares with the runtime settings layers. - Clearing the global settings drops what the server last sent, so logout does not leave the previous account's data on the home screen. - iOS refuses cross-host redirects and abandons an oversized body mid-stream instead of buffering it against a 30 MB ceiling. - Receivers schedule under goAsync(), so a widget added while the app is not running cannot lose its schedule. - Android draws the refresh button for server-driven Dynamic Widgets, which was configurable but unwired. - An interval from app.json is no longer re-clamped, so an existing iOS payload widget keeps the schedule it was configured with. - An oversized body reports error 'parse' rather than 'http 200'. - iOS sends locale as a BCP-47 tag, matching Android and the documented contract. - updateDynamicWidget's own reload no longer immediately refetches over the props it just wrote. - The Expo iOS plugin enforces the groupIdentifier a server-driven Dynamic Widget needs, which only the CLI checked.
V3RON
force-pushed
the
feat/server-driven-dynamic-widgets
branch
from
September 5, 2026 19:05
f4ee09d to
42ed176
Compare
V3RON
changed the base branch from
main
to
claude/dynamic-server-widgets-plan-p8rlok
September 5, 2026 19:05
Contributor
|
@V3RON : if I have: a) b) Who has the highest priority? Scenario:
Am I wrong? |
GlanceAppWidgetReceiver already calls goAsync() in its own onReceive, so the second call from onUpdate returns null and the widget crashes with an NPE on PendingResult.finish() as soon as an update is dispatched. Enqueue the scheduling work with runBlocking instead. onReceive still does not return before the work is enqueued -- which is the guarantee that matters, since a widget added while the app is not running would otherwise lose its schedule entirely -- and it matches how the payload receiver has always done it.
Reads server-update settings back through the same resolver that already flattens them for a fetch, rather than inferring state via the /echo trick. With a widgetId, returns the fully resolved, defaulted settings; without one, the raw GLOBAL layer only, since there is nothing to resolve against without a widget scope.
V3RON
marked this pull request as ready for review
September 8, 2026 17:25
Contributor
Author
The in-app overwrite is locally persisted and has greater priority than the defaults set in |
- correct Android intervalMinutes default (60, 15 with entry) - complete the debug-build local-host allowlist on both platforms - document the getWidgetServerUpdate read-back API - drop WorkManager-internal wording from the refresh button and interval floor explanations
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.
Implements ADR 0002. Stacked on #266, which adds the ADR; review and merge that first, and this retargets to
main. Closes #176.What is this?
A widget that wants fresh data without the app running has had one option:
serverUpdateon a payload-driven widget, where the server runs Voltra's JSX renderer and returns a full UI payload. That rules out any backend not written in Node, the fetch is GET-only, and the URL is frozen at build time with no way to tell the server which account or range the widget wants.Giving a widget both
entryandserverUpdatenow makes it a server-driven Dynamic Widget: the device fetches a plain JSON object from the configured URL and passes it to the bundled JS as props. Rendering stays on the device and the server never sees a component tree, so a hand-written PHP script is enough.serverUpdatein app.json also becomes a set of defaults rather than the final word.setWidgetServerUpdatelets an app change the URL, interval, method, query, headers and body at runtime — per widget or for all of them, for both render engines — and turn fetching off entirely to drive a widget itself.How does it work?
entrydecides how a widget renders andserverUpdatedecides where its data comes from, so the two keys together select one of four providers on iOS and receivers on Android. That choice is made once, when the native project is generated; no runtime code asks whether a widget is server-driven.Both engines read their settings through one resolver per platform, composed of four layers stacked in a fixed order — app.json config, the deprecated credentials, global runtime settings, per-widget runtime settings.
headersandquerymerge per key; everything else takes the value from the most specific layer that sets it. That rule lives in the resolver and nowhere else, and the same code builds every request, which is what gives payload widgets runtime URLs and non-GET methods too. Everything per widget is keyed by aWidgetScoperather than a bare id, so per-instance fetches can be added later without changing callers.Every fetch is fetch, parse, trial-render, commit. Props are rendered once off screen before they are committed, so a response the widget cannot draw leaves the last good one on screen rather than blanking the widget. A body that is not a JSON object is rejected, and one shaped like a Voltra payload is rejected by name, since pointing a widget with an
entryat a payload endpoint is the mistake sharing the config key invites.ETag,Cache-Control: max-ageandRetry-Afterare honoured, clamped to what each platform can schedule.Widgets see the outcome on
env.serverUpdate—status,fetchedAt,error,httpStatus— so they can show "updated 3 min ago", dim when the data is stale, or hide the freshness line while the app has taken them over. That is the only change to the existing Dynamic render path: an environment seam the plain engine leaves empty.On Android the work runs in the app process through WorkManager and never pushes
RemoteViews; drawing stays inVoltraClientGlanceWidget. On iOS the new timeline provider wraps the existing one, keepingplaceholderandgetSnapshotoff the network.Why is this useful?
Any backend can drive a widget now, in any language, by returning the data it already has instead of learning Voltra's renderer. Per-tenant deployments can ship a widget with no URL at all and supply one after login. An app can point a widget at a different endpoint, add an auth header, or take the widget over entirely without a rebuild.
Payload widgets get the same runtime settings, ETag revalidation and
localeparameter without any config change, and keep their existing request and schedule when no runtime settings are set.