Skip to content

feat: server-driven Dynamic Widgets - #271

Merged
V3RON merged 15 commits into
claude/dynamic-server-widgets-plan-p8rlokfrom
feat/server-driven-dynamic-widgets
Sep 9, 2026
Merged

feat: server-driven Dynamic Widgets#271
V3RON merged 15 commits into
claude/dynamic-server-widgets-plan-p8rlokfrom
feat/server-driven-dynamic-widgets

Conversation

@V3RON

@V3RON V3RON commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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: serverUpdate on 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 entry and serverUpdate now 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.

serverUpdate in app.json also becomes a set of defaults rather than the final word. setWidgetServerUpdate lets 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?

entry decides how a widget renders and serverUpdate decides 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. headers and query merge 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 a WidgetScope rather 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 entry at a payload endpoint is the mistake sharing the config key invites. ETag, Cache-Control: max-age and Retry-After are honoured, clamped to what each platform can schedule.

Widgets see the outcome on env.serverUpdatestatus, 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 in VoltraClientGlanceWidget. On iOS the new timeline provider wraps the existing one, keeping placeholder and getSnapshot off 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 locale parameter without any config change, and keep their existing request and schedule when no runtime settings are set.

V3RON added 12 commits September 5, 2026 21:04
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
V3RON force-pushed the feat/server-driven-dynamic-widgets branch from f4ee09d to 42ed176 Compare September 5, 2026 19:05
@V3RON
V3RON changed the base branch from main to claude/dynamic-server-widgets-plan-p8rlok September 5, 2026 19:05
@Angelk90

Angelk90 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

@V3RON :

if I have:

a)

// app.json
{
  "url": "https://api.example.com/portfolio",
  "intervalMinutes": 30
}

b)

setWidgetServerUpdate(
  {
    url: "https://customer.example.com/portfolio",
    intervalMinutes: 60
  },
  { widgetId: "portfolio" }
)

Who has the highest priority?

Scenario:

  1. I open an app and then close it, so it should have a higher priority of b)

  2. I restart the smartphone, so it should have a higher priority of a), because I haven't opened any apps, so it should remain the default value.

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
V3RON marked this pull request as ready for review September 8, 2026 17:25
@V3RON

V3RON commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

@V3RON :

if I have:

a)

// app.json
{
  "url": "https://api.example.com/portfolio",
  "intervalMinutes": 30
}

b)

setWidgetServerUpdate(
  {
    url: "https://customer.example.com/portfolio",
    intervalMinutes: 60
  },
  { widgetId: "portfolio" }
)

Who has the highest priority?

Scenario:

  1. I open an app and then close it, so it should have a higher priority of b)
  2. I restart the smartphone, so it should have a higher priority of a), because I haven't opened any apps, so it should remain the default value.

Am I wrong?

The in-app overwrite is locally persisted and has greater priority than the defaults set in app.json.

- 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
@V3RON
V3RON merged commit 65bf5be into main Sep 9, 2026
14 checks passed
@V3RON
V3RON deleted the feat/server-driven-dynamic-widgets branch September 9, 2026 02:20
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.

Be able to update the widget with json data without using widget-server

2 participants