Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion android/src/main/java/voltra/widget/VoltraWidgetUpdateWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package voltra.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.res.Configuration
import android.util.Log
import android.widget.RemoteViews
import androidx.work.CoroutineWorker
Expand Down Expand Up @@ -57,10 +58,15 @@ class VoltraWidgetUpdateWorker(

try {
// 1. Build URL with query parameters
val nightModeFlags =
applicationContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"

val urlBuilder = StringBuilder(serverUrl)
urlBuilder.append(if (serverUrl.contains("?")) "&" else "?")
urlBuilder.append("widgetId=").append(widgetId)
urlBuilder.append("&platform=android")
urlBuilder.append("&theme=").append(theme)

val url = URL(urlBuilder.toString())
val connection = url.openConnection() as HttpURLConnection
Expand All @@ -70,7 +76,11 @@ class VoltraWidgetUpdateWorker(
connection.connectTimeout = 15000
connection.readTimeout = 15000
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", "VoltraWidget/${BuildConfig.VOLTRA_VERSION}")
val androidVersion = android.os.Build.VERSION.RELEASE
connection.setRequestProperty(
"User-Agent",
"VoltraWidget/${BuildConfig.VOLTRA_VERSION} (Android/$androidVersion)",
)

// 2. Add auth token from encrypted storage
val token = VoltraWidgetCredentialStore.readToken(applicationContext)
Expand Down
22 changes: 21 additions & 1 deletion ios/shared/VoltraWidgetServerFetcher.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import UIKit

/// Handles fetching widget content from a remote Voltra SSR server.
/// Used by the TimelineProvider to pull server-driven widget updates.
Expand Down Expand Up @@ -59,12 +60,27 @@ public enum VoltraWidgetServerFetcher {
return 60 // default: 1 hour
}

private static func currentColorScheme() -> String {
if #available(iOSApplicationExtension 13.0, *) {
switch UITraitCollection.current.userInterfaceStyle {
case .dark:
return "dark"
case .light:
return "light"
default:
return "light"
}
}
return "light"
}

/// Fetch widget content from the remote Voltra SSR server.
///
/// The request includes:
/// - `widgetId` query parameter
/// - `family` query parameter (e.g., "systemSmall")
/// - `platform` query parameter (`ios`)
/// - `theme` query parameter (`light` or `dark`)
/// - `Authorization: Bearer <token>` header (if credentials stored in Keychain)
/// - Any custom headers stored in Keychain
///
Expand All @@ -82,10 +98,13 @@ public enum VoltraWidgetServerFetcher {
throw FetchError.invalidUrl(baseUrl)
}

let theme = currentColorScheme()

var queryItems = components.queryItems ?? []
queryItems.append(URLQueryItem(name: "widgetId", value: widgetId))
queryItems.append(URLQueryItem(name: "family", value: family))
queryItems.append(URLQueryItem(name: "platform", value: "ios"))
queryItems.append(URLQueryItem(name: "theme", value: theme))
components.queryItems = queryItems

guard let url = components.url else {
Expand All @@ -109,8 +128,9 @@ public enum VoltraWidgetServerFetcher {
}

// Add Voltra-specific headers
let systemVersion = UIDevice.current.systemVersion
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("VoltraWidget/1.0", forHTTPHeaderField: "User-Agent")
request.setValue("VoltraWidget/1.0 (iOS/\(systemVersion))", forHTTPHeaderField: "User-Agent")

do {
let (data, response) = try await URLSession.shared.data(for: request)
Expand Down
15 changes: 15 additions & 0 deletions src/widget-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,20 @@ export type { WidgetVariants } from './widgets/types.js'
*/
export type WidgetPlatform = 'ios' | 'android'

/**
* The system color scheme reported by the native widget.
* Read from the `theme` query parameter.
*/
export type WidgetTheme = 'light' | 'dark'

function isWidgetPlatform(value: string | null): value is WidgetPlatform {
return value === 'ios' || value === 'android'
}

function isWidgetTheme(value: string | null): value is WidgetTheme {
return value === 'light' || value === 'dark'
}

type NodeLikeRequest = IncomingMessage & {
url?: string
method?: string
Expand All @@ -45,6 +55,8 @@ export interface WidgetRenderRequest {
widgetId: string
/** The platform the request is coming from */
platform: WidgetPlatform
/** The system color scheme (`light` or `dark`). Defaults to `light` when not provided. */
theme: WidgetTheme
/** The widget family/size (iOS only: "systemSmall", "systemMedium", etc.) */
family?: string
/** The authorization token from the request (if present) */
Expand Down Expand Up @@ -173,6 +185,8 @@ export function createWidgetUpdateHandler(options: WidgetUpdateHandlerOptions):
}

const platform: WidgetPlatform = platformParam
const themeParam = url.searchParams.get('theme')
const theme: WidgetTheme = isWidgetTheme(themeParam) ? themeParam : 'light'

// Extract auth token
const authHeader = request.headers.get('authorization')
Expand All @@ -194,6 +208,7 @@ export function createWidgetUpdateHandler(options: WidgetUpdateHandlerOptions):
const renderRequest: WidgetRenderRequest = {
widgetId,
platform,
theme,
family,
token,
headers: normalizeHeaders(request.headers),
Expand Down
8 changes: 6 additions & 2 deletions website/docs/android/development/server-driven-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Add the `serverUpdate` option to your Android widget in `app.json` or `app.confi

**`serverUpdate` options:**

- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId` and `platform=android` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=android`).
- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=android&theme=dark`).
- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. The minimum effective interval is 15 minutes (WorkManager requirement).

After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native widget code picks up the new server update settings.
Expand All @@ -71,6 +71,7 @@ const handler = createWidgetUpdateNodeHandler({
renderAndroid: async (req) => {
// req.widgetId — the widget requesting an update
// req.platform — always "android" for Android widget requests
// req.theme — the system color scheme ("light" or "dark")
// req.token — the auth token (if credentials were set)

const weather = await fetchWeatherData()
Expand Down Expand Up @@ -124,6 +125,9 @@ The handler responds to GET requests with these query parameters:
| `widgetId` | The widget identifier (required) |
| `platform` | The requesting platform. Must be `android` or `ios` (required). |
| `family` | The widget family/size (iOS only — absent for Android) |
| `theme` | The system color scheme (`light` or `dark`) |

The `User-Agent` header is set to `VoltraWidget/<version> (Android/<version>)`.

## Authentication

Expand Down Expand Up @@ -233,7 +237,7 @@ If you're serving the endpoint from Node or Express, use `createWidgetUpdateNode
└─────────────────┘ │
│ reads token
┌─────────────────┐ GET ?widgetId=X&platform=android ┌──────────────────┐
┌─────────────────┐ GET ?widgetId=X&platform=android&theme=Y ┌──────────────────┐
│ WorkManager │ ─────────────────────────────► │ Your Server │
│ (background) │ ◄───────────────────────────── │ (Voltra SSR) │
└─────────────────┘ JSON payload └──────────────────┘
Expand Down
8 changes: 5 additions & 3 deletions website/docs/ios/development/server-driven-widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Add the `serverUpdate` option to your widget in `app.json` or `app.config.js`:

**`serverUpdate` options:**

- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform=ios`, and `family` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=ios&family=systemSmall`).
- `url`: The Voltra SSR endpoint that returns widget JSON. Voltra appends `widgetId`, `platform`, `family`, and `theme` query parameters automatically (e.g. `?widgetId=dynamic_weather&platform=ios&family=systemSmall&theme=dark`).
- `intervalMinutes`: How often the widget fetches updates. Defaults to `15`. iOS WidgetKit may throttle requests; the minimum effective interval is ~15 minutes.

After updating plugin configuration, run `npx expo prebuild` if you're using Continuous Native Generation, then rebuild the app so the generated native files and widget extension pick up the new server update settings.
Expand All @@ -64,6 +64,7 @@ const handler = createWidgetUpdateNodeHandler({
// req.widgetId — the widget requesting an update
// req.platform — always "ios" for iOS widget requests
// req.family — the widget size ("systemSmall", "systemMedium", etc.)
// req.theme — the system color scheme ("light" or "dark")
// req.token — the auth token (if credentials were set)

const weather = await fetchWeatherData()
Expand Down Expand Up @@ -107,8 +108,9 @@ The handler responds to GET requests with these query parameters:
| `widgetId` | The widget identifier (required) |
| `platform` | The requesting platform. Must be `ios` for iOS widgets (required). |
| `family` | The widget family/size (iOS only) |
| `theme` | The system color scheme (`light` or `dark`) |

The `Authorization: Bearer <token>` header is automatically extracted and passed to `validateToken` and `renderIos`.
The `Authorization: Bearer <token>` header is automatically extracted and passed to `validateToken` and `renderIos`. The `User-Agent` header is set to `VoltraWidget/1.0 (iOS/<version>)`.

For Fetch-native runtimes, use `createWidgetUpdateHandler()` instead of the Node adapter:

Expand Down Expand Up @@ -226,7 +228,7 @@ Provide a meaningful initial state (e.g. "Loading..." or placeholder content) ra
└─────────────────┘ │
│ reads token
┌─────────────────┐ GET ?widgetId=X&platform=ios&family=Y ┌──────────────────┐
┌─────────────────┐ GET ?widgetId=X&platform=ios&family=Y&theme=Z ┌──────────────────┐
│ WidgetKit │ ──────────────────────────────────► │ Your Server │
│ (extension) │ ◄────────────────────────────────── │ (Voltra SSR) │
└─────────────────┘ JSON payload └──────────────────┘
Expand Down
Loading