rerune 0.14.1
rerune: ^0.14.1 copied to clipboard
OTA localization updates for Flutter apps with build_runner code generation.
rerune #
OTA localization updates for Flutter apps.
ReRune layers server-provided translations on top of your generated
AppLocalizations values, without changing how you call localization getters in
widgets.
Locale transport is codec agnostic. The SDK fetches generic backend JSON, validates and caches a complete locale document, and renders plain values and structured cardinal plurals through the generated Flutter localization methods. Consumer widgets do not call a separate ReRune placeholder or plural API.
Remote Language Delivery #
ReRune's core OTA value is remote language delivery: add a new supported language in the ReRune dashboard and make it available to already-installed apps without shipping a new app-store build just for that language.
After the SDK fetches or loads the dashboard language bundle from cache, the
locale appears in ReRune.supportedLocales. Apps can let the device locale
resolve to it automatically, or build a language picker from
ReRune.supportedLocales so remote languages appear next to compiled app
locales.
The app still ships the generated localization API and at least one compiled fallback locale. ReRune updates values for existing keys and expands the runtime locale list from dashboard data.
Requirements #
- Flutter
>=3.22.0 - Dart
>=3.4.0
Install #
dependencies:
rerune: ^0.14.1
dev_dependencies:
# Optional: needed only if you use the build_runner path below.
build_runner: ^2.4.13
Standard Integration (Recommended) #
1) Generate Flutter localizations #
flutter gen-l10n
2) Generate ReRune localization config #
Fast path (ReRune-only generation):
dart run rerune
Alternative (build_runner pipeline):
dart run build_runner build --delete-conflicting-outputs
Both commands generate identical *.rerune.g.dart artifacts.
Parity is protected by dedicated drift-guard tests in CI.
With default Flutter l10n naming, ReRune generates:
lib/.../app_localizations.rerune.g.dartreRuneAppLocalizationsConfig
No manual anchor class or annotation file is needed.
3) Wire ReRune.setup(...) in main() #
import 'package:flutter/widgets.dart';
import 'package:rerune/rerune.dart';
import 'l10n/gen/app_localizations.rerune.g.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig, // Generated by rerune/build_runner
updatePolicy: const ReRuneUpdatePolicy(checkOnStart: true),
);
runApp(const MyApp());
}
4) Use ReRune delegates/locales in your app #
MaterialApp(
localizationsDelegates: ReRune.localizationsDelegates,
supportedLocales: ReRune.supportedLocales,
)
Use ReRune.supportedLocales anywhere you need the current runtime locale
list, including custom language pickers. The list starts with compiled app
locales and expands with fetched or cached dashboard languages.
Runtime APIs #
- Setup and load cached bundles:
await ReRune.setup(...) - Check manually:
await ReRune.checkForUpdates() - Listen when fetched OTA message values are applied:
ReRune.onFetchedTextsApplied.listen(...) - Rebuild subtree on any localization revision:
ReRuneBuilder(builder: ...) - Listen to any localization revision:
ReRune.localizationsRevisionListenable
ReRune does not force Flutter's root element to rebuild. Without
ReRuneBuilder or an application-owned revision listener, existing widgets
remain unchanged until the application rebuilds them for another reason.
Generated localization getters resolve against the current in-memory OTA
bundles on every invocation, so newly built widgets still receive the latest
loaded values.
Diagnostic logging #
ReRune logging is disabled by default. Consumers can select a level during setup:
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
logLevel: ReRuneLogLevel.info,
);
| Level | Output |
|---|---|
ReRuneLogLevel.off |
No diagnostics. This is the default. |
ReRuneLogLevel.error |
High-level failures without causes, stack traces, headers, or response bodies. |
ReRuneLogLevel.info |
Failures plus request URLs, operations, and response status codes. |
ReRuneLogLevel.verbose |
All available diagnostics, including raw headers, response bodies, causes, and stack traces. |
verbose is an explicit sensitive-data opt-in. It can expose the OTA publish
ID and translated content in application logs. The lower levels never emit
request headers or response bodies.
Dashboard-Only Locale Additions #
This is the remote-language delivery path for apps that want language expansion
to be controlled from the dashboard. await ReRune.setup(...) loads the cached
manifest and generic locale bundles before the first app frame, so a
dashboard-only locale fetched during a previous run is available in
ReRune.supportedLocales before MaterialApp resolves the device locale. If
checkOnStart is enabled, the network refresh starts in the background after
cached bundles are loaded; it does not block setup.
After a successful manifest/update fetch, ReRune.supportedLocales contains
the app's compiled locales plus dashboard locales whose locale bundle has been
fetched or loaded from cache. The generated delegate uses the app's first
compiled locale as the fallback base when Flutter's generated delegate cannot
load a dashboard-only locale.
When a newly fetched manifest no longer lists a previously published locale,
ReRune removes that locale's in-memory OTA bundle and persistent cache. A
dashboard-only locale then disappears from ReRune.supportedLocales; a
compiled locale remains supported by the app but falls back through the
remaining OTA chain and bundled translations.
Missing-Key OTA Fallback #
The backend manifest declares the project's source locale through
the required main_language field. The value must identify one of the
manifest's locales. When a key is missing from the requested OTA locale,
ReRune resolves it in this order:
requested OTA locale variants
-> OTA main_language variants
-> bundled Flutter localization
For example, an es_MX request with main_language: "en" resolves through
es_MX, es, and then OTA en before using the bundled Flutter value. Lookup
is per key, so an available es_MX bundle that lacks one key does not prevent
that key from falling back through es and en.
Placeholder, plural, and select messages use the locale of the OTA bundle that supplied the value. An English OTA fallback therefore uses English plural rules even when the active app locale is Spanish, Polish, or Arabic.
Manifests without a valid main_language are rejected. ReRune does not infer a
main language from locale ordering, compiled locales, or the active app locale.
Language Pickers #
Build language pickers from ReRune.supportedLocales rather than from the
compiled AppLocalizations.supportedLocales list. That gives consumers one
source of truth for:
- locales bundled into the app at build time
- dashboard-only locales that were fetched in this run
- dashboard-only locales loaded from cache during startup
If a picker must update immediately after a same-run background fetch, rebuild
it from ReRuneBuilder or listen to ReRune.localizationsRevisionListenable.
Without that rebuild, the newly fetched language is available on the next app
launch after await ReRune.setup(...) loads it from cache.
Use ReRune.localeName(locale) for the backend-provided display name committed
with a locale's cached translations. It follows the same normalized locale
fallback order as translation lookup and returns null when no successfully
applied cache has a matching entry. Name-only manifest changes advance the
general localization revision after the locale metadata is persisted.
ReRune does not own the selected app locale. Keep that state in your app, for
example with a small LocaleNotifier, Riverpod, Bloc, or your existing app
settings model. Use ReRune.supportedLocales for picker options and pass your
selected locale to MaterialApp.locale. Apps without a language picker can
omit MaterialApp.locale entirely and keep Flutter's normal system-locale
resolution.
class LocaleNotifier extends ValueNotifier<Locale?> {
LocaleNotifier(super.value);
}
final LocaleNotifier localeNotifier = LocaleNotifier(null);
ReRuneBuilder(
builder: (_) => ValueListenableBuilder<Locale?>(
valueListenable: localeNotifier,
builder: (_, locale, __) {
return MaterialApp(
locale: locale, // Optional: only for app-owned picker overrides.
localizationsDelegates: ReRune.localizationsDelegates,
supportedLocales: ReRune.supportedLocales,
);
},
),
)
Set the notifier from your picker. Setting it to null returns to Flutter's
normal device-locale resolution.
DropdownButton<Locale?>(
value: localeNotifier.value,
hint: const Text('System default'),
items: [
const DropdownMenuItem<Locale?>(
value: null,
child: Text('System default'),
),
...ReRune.supportedLocales.map((locale) {
return DropdownMenuItem<Locale?>(
value: locale,
child: Text(locale.toLanguageTag()),
);
}),
],
onChanged: (locale) {
localeNotifier.value = locale;
},
)
Any non-null selected locale should come from ReRune.supportedLocales. A
custom LocaleNotifier does not collide with ReRune because ReRune does not
expose its own selected-locale setter. ReRune does not fetch unavailable
languages from picker selection; dashboard-only languages appear in the list
only after their locale bundle is fetched or loaded from cache.
Startup Modes #
Use awaited setup when you want deterministic second-run locale support without waiting on the network:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
);
runApp(const MyApp());
}
Use the builder when you also want the app to follow the device language immediately after a same-run background fetch:
ReRuneBuilder(
builder: (_) => MaterialApp(
localizationsDelegates: ReRune.localizationsDelegates,
supportedLocales: ReRune.supportedLocales,
),
)
Limits:
- Without
ReRuneBuilderor another rebuild trigger, existing widgets do not refresh solely because OTA content changed. Newly built widgets still resolve current OTA values for their loaded locale. - A dashboard-only locale fetched during the current run requires
MaterialAppto rebuild before Flutter can reconsidersupportedLocalesand locale selection. Otherwise it is picked up on the next launch afterawait ReRune.setup(...)loads it from cache. - ReRune does not store the selected app locale. Apps with language pickers
should keep that state themselves and pass it to
MaterialApp.locale;nullstill means Flutter resolves the system language normally. - New localization keys or changed placeholder signatures still require running
flutter gen-l10nanddart run rerune, then shipping an app update. - Platform/app-store language metadata and OS-level language listings are not changed by OTA localization.
Advanced And Custom Integrations #
Custom Flutter l10n class/file names #
If your app does not use app_localizations.dart / AppLocalizations, add a
build.yaml in the app root:
targets:
$default:
builders:
rerune|re_rune_localizations_overlay:
generate_for:
- lib/**/my_localizations.dart
options:
localizations_file_name: my_localizations.dart
localizations_class_name: MyLocalizations
Then run:
flutter gen-l10n
dart run rerune
# or
dart run build_runner build --delete-conflicting-outputs
This generates my_localizations.rerune.g.dart and
reRuneMyLocalizationsConfig.
Use it in startup:
import 'package:rerune/rerune.dart';
import 'l10n/gen/my_localizations.rerune.g.dart';
void main() {
ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneMyLocalizationsConfig,
);
runApp(const MyApp());
}
Disable automatic startup fetch #
ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig, // Generated by rerune/build_runner
updatePolicy: const ReRuneUpdatePolicy(checkOnStart: false),
);
Schedule periodic refresh in hours or days #
ReRune.setup(
otaPublishId: 'your-ota-publish-id',
localizations: reRuneAppLocalizationsConfig,
updatePolicy: const ReRuneUpdatePolicy(
checkOnStart: true,
periodicIntervalInHours: 2,
periodicIntervalInDays: 3,
),
);
ReRuneUpdatePolicy only accepts whole-hour or whole-day periodic refresh intervals.
If both fields are set, they are combined into a single refresh cadence.
On web, if the combined interval exceeds the supported timer limit, ReRune
clamps it to the maximum supported delay (24 days and 20 hours) instead of
throwing.
Trigger updates from UI actions #
final result = await ReRune.checkForUpdates();
if (result.hasErrors) {
// show error state
}
for (final locale in result.updatedLocales) {
// Fetched OTA message values changed.
}
for (final locale in result.updatedLocaleNames) {
// The successfully applied manifest display name changed.
}
for (final locale in result.removedLocales) {
// The locale is no longer published in the manifest.
}
result.hasUpdates is true when fetched values, locale names, or publication
membership changed. Name-only changes and unpublishing do not emit
ReRune.onFetchedTextsApplied; they increment
ReRune.localizationsRevisionListenable when visible runtime state changed.
Generic locale payloads #
The manifest is requested from
https://rerune.io/api/sdk/translations/manifest without a platform query.
Each manifest locale URL returns a JSON array containing only that locale:
[
{
"key": "publish_date",
"values": [
{
"lang": "en",
"value": "we did publish on {{publish_date}}"
}
],
"placeholders": [
{"name": "publish_date", "type": "string"}
]
},
{
"key": "horse_count",
"values": [
{
"lang": "en",
"message": {
"parts": [
{
"variant": {
"variable": "count",
"forms": [
{
"selector": "one",
"parts": [{"text": "is {{amount}} horse"}]
},
{
"selector": "other",
"parts": [{"text": "are {{amount}} horses"}]
}
]
}
}
]
}
}
],
"placeholders": [
{"name": "amount", "type": "int"}
]
}
]
Every record has a unique key and zero or one locale values. With one value,
its normalized lang must match the requested locale. An empty values array
means the key has no translation for that locale: ReRune retains the opaque
record in its canonical cache, omits it from the runtime locale view, and
continues through the normal OTA and bundled fallback chain. By contrast, a
single plain value containing "" is an intentional empty translation.
Plain values must be strings. Structured messages contain one plural variant;
selectors use the six lowercase cardinal categories (zero, one, two,
few, many, other) and must include other. A record is projected into
the runtime overlay only when every visible {{placeholder}} token has an
exact, case-sensitive declaration. A token/declaration mismatch does not reject
the response: ReRune preserves the record in canonical cache, omits that key
from the overlay, and continues through normal per-key fallback. In a delta,
this also clears an older runtime override for the key.
Placeholder types accept string and int case-insensitively; runtime values
are passed through without additional type enforcement, except that the plural
control argument must be numeric. A structurally invalid response is rejected
for the complete locale and the last committed bundle stays active. Missing or
incompatible runtime arguments fall back only that generated message to its
bundled Flutter value.
Incremental locale updates #
Locale downloads are addressed by the locale version in the manifest. A full
download requests GET {url}?target_version=<manifest locale version>. A delta
requests
GET {url}?version=<successfully applied cache version>&target_version=<manifest locale version>.
Every manifest locale also declares minimum_delta_base_version, which ReRune
persists independently with that locale's cache.
When the manifest value differs from the cached
minimum_delta_base_version, ReRune performs a full request and replaces the
cached locale. Otherwise, different applied and target versions use a delta.
Equal versions and minimum values skip the locale request when the complete
cached locale document remains valid. Missing, unreadable, or invalid locale
cache content is not a delta base and therefore also uses a full request.
ReRune merges a delta response into the complete cached locale document;
incoming records replace complete cached records with the same key, while
absent keys remain unchanged. An incoming record with values: [] clears a
previously cached runtime translation for that locale, causing normal fallback,
while retaining the opaque record in the canonical snapshot. Deleting the
translation key itself is not represented by a delta record; the backend
raises minimum_delta_base_version to force a full response. A full response
replaces the complete locale, so keys absent from that response are removed.
The manifest locale name,
version, minimum_delta_base_version, and original url become the applied
locale metadata only after parsing and cache commit succeed. Empty or identical
responses advance that metadata without emitting a text-update event. When
versions already match, a name- or URL-only manifest change updates the locale
cache metadata without downloading text.
A locale 409 means the manifest target is stale. ReRune stops that pass,
fetches the manifest once more without If-None-Match, and restarts locale
synchronization from the committed per-locale versions. Already committed
locales remain available. A second 409 stops the current check and reports a
network error; a later check receives its own single recovery attempt.
Locale request failures are reported as network errors, invalid locale documents or merge failures as parse errors, and failed locale cache commits as storage errors. A failed locale does not stop synchronization of the remaining published languages.
Provide your own cache store #
If you need custom storage behavior, implement ReRuneCacheStore and pass it
to ReRune.setup(cacheStore: ...). Custom stores must persist all
ReRuneCachedLocaleBundle fields: the complete canonical generic JSON data
and the applied manifest locale name, version,
minimumDeltaBaseVersion, and url. Implement readLocaleBundle(...),
writeLocaleBundle(...), and deleteLocaleBundle(...) for locale state. A
custom write must not expose a candidate as committed when it throws; preserve
the previous complete payload and metadata until the replacement succeeds.
Custom stores must return null from readManifest() or
readLocaleBundle(...) only when the requested entry does not exist. If
persisted state exists but cannot be read or decoded, the read must throw so
ReRune can report a storage error and repair locale content through a complete
request.
The default cache uses the new rerune_locale_cache_v1 namespace
(locale_<locale>.json on IO platforms and _locale_<locale> preference
keys on web). Entries in the former ota_localizations ARB namespace are
ignored and are not migrated or deleted.
Troubleshooting #
Target of URI hasn't been generated: runflutter gen-l10n, thendart run rerune(ordart run build_runner build --delete-conflicting-outputs).Undefined name reRune...Config: generated.rerune.g.dartfile is missing, stale, or imported from a wrong path.- Changed l10n keys/signatures: rerun both generators.
Manual platform verification #
Use the Flutter SDK manual test protocol to validate a release against the real ReRune platform on physical Android and iOS devices, Chrome, and Safari.
License #
This package is available under the MIT License. See LICENSE.
- Issue tracker:
https://rerune.io/issue-tracker