Skip to content

Commit dbd5fe1

Browse files
authored
feat(web): bidirectional message channel between page and background worker (#708)
* feat(web): bidirectional message channel between page and background worker Adds free-form two-way messaging between the app and the background worker, complementing request/response task execution: - WorkerProtocol: new 'message' (page -> worker) and 'workerMessage' (worker -> page) wire types - WorkmanagerExecution: messageHandler + sendToPage hooks on the Flutter-free dispatcher side - WorkmanagerWebWorker runtime: routes messages and wires sendToPage for both the dedicated Web Worker (postMessage) and the Service Worker (clients.postMessage) - WorkmanagerWeb: sendMessageToWorker() + workerMessages stream, including the in-page fallback path The web example demonstrates the channel with a live worker chat and a simulated price-watch use case (watch/stop/check messages, live ticks, and background task checks). Rebuilt web/background.dart.js. * refactor(example): simplify web demo UI — tabs, auto-registered task, fewer buttons The web demo had five action buttons plus four chips stacked with the chat and the event log. Rework it around two tabs: - Worker chat: conversation + message input + three action chips - Task log: single 'Run check now' action + event list The periodic price-check task is now registered automatically on startup so the demo works with zero setup; cancel moved to the app bar menu. Status is a single line instead of a paragraph. * style: dart format web demo page * feat(web): rework example demo — weather watch, explanatory UI, high-contrast theme Replace the BTC/ETH simulated price watch with a simulated weather watch (cities + °C thresholds). Add a 'How this demo works' card explaining what the demo does, what is being tested (messaging, background tasks, Service Worker) and how to use it; explain event states in the task log. High-contrast color scheme and larger fonts throughout. Rebuilt web/background.dart.js. * feat(web): demo notifications + Guide tab - Browser notifications when a background task finishes: the Service Worker shows them via registration.showNotification while the tab is closed (Flutter-free bundle, dart:js_interop), the page shows them while open (permission button on the Task log tab). - New Guide tab: what the demo tests (messaging / background tasks / Service Worker) and a 6-step walkthrough that answers the key question explicitly — yes, close the tab; trigger Periodic Background Sync in DevTools; watch the notification; reopen to see the replay. - Chat tab intro trimmed (details moved to Guide); watch thresholds tuned so the alert path actually fires with the simulated data. - Rebuilt web/background.dart.js. * fix(web): visible tab labels on the app bar M3 TabBar defaults labelColor to colorScheme.primary (blue) — invisible on the blue app bar. Set white labels + white indicator, light-blue unselected labels via TabBarTheme. * docs(web): explain what PWA install means and how to do it Guide step 3 now says installing = adding the demo to the device like a native app (what enables no-tab background execution), with both paths: the in-app button and Chrome's address-bar install icon. Task log tab hints when the install button isn't shown by the browser. * feat(web): always-visible 'Install the app' button with fallback The install button no longer hides until Chrome fires beforeinstallprompt. It is always on the Task log tab (and inline in the Guide): tap it to show the browser's PWA install prompt; if Chrome has no prompt yet, a dialog explains how to install (address-bar ⊕ icon, localhost/HTTPS requirement) and what installing means. Tracks the appinstalled event and flips to 'App installed' when done. promptInstall() now returns whether the user accepted. * feat(web): foreground sees background runs — events replay into chat Every executed background task now also appears in the chat as a 'background: …' line (with source), including runs that happened while the page was closed — replayed from the persistent IndexedDB queue on load. Task log relabeled as the persistent background queue (single source of truth); Guide explains it (point 4) and includes a dev note about flutter-run hot restarts being buggy on web. * fix(web): chat scrolls to latest and fills available height Chat list was appending messages with reverse:true, pinning the view on the oldest messages. Insert new messages at index 0 (bottom-pinned with reverse:true) so the latest is always visible. Chat area now expands to the full tab height instead of a fixed 220px box. * docs(web): remove developer note from Guide tab * feat(web): install button also on the Chat tab (first screen) * feat(web): deploy demo to GitHub Pages + subpath-safe default URLs - New pages-demo workflow: builds the web demo (Flutter 3.44.8 via .fvmrc, melos bootstrap) with --base-href=/flutter_workmanager/ and force-pushes build/web to the gh-pages branch (served at https://fluttercommunity.github.io/flutter_workmanager/). - WorkmanagerWeb defaults (serviceWorkerUrl/dispatcherUrl) now resolve against Uri.base instead of the origin root, so subpath deployments (Pages project sites) load the Service Worker + dispatcher bundle correctly; root deployments behave exactly as before. * feat(web): intro landing page before the demo The web demo site now opens on an intro page instead of going straight into the demo: what workmanager is, platform support, a feature summary, a demo card (what it shows, PWA note) and resource links (pub.dev, GitHub, docs.page, issues) that open in a new tab. DRY: shared high-contrast theme + palette extracted to app_theme.dart and used by both the landing page and the demo; root widget moved to web_app.dart (LandingPage home, demo pushed as a route with back navigation). No image assets — icons and typography only. * ci(web): serve CanvasKit from gstatic CDN --web-resources-cdn drops the 26MB local CanvasKit bundle (7.2MB wasm, served uncompressed by GitHub Pages) from the deploy; the renderer now loads from Google's gstatic CDN, which serves it gzipped and cached. GitHub Pages itself does not compress responses, so main.dart.js still transfers at its raw 2.5MB (gzip would make it ~0.7MB — needs a compression-capable edge in front, e.g. Cloudflare). * ci(web): drop the local CanvasKit bundle from the gh-pages deploy * fix(web): conditional-import glue for JS bridges (native builds) Direct dart:js_interop imports in landing_page.dart, web_demo_page.dart and background_tasks.dart broke native example builds ('Dart library 'dart:js_interop' is not available on this platform'). Moved all JS interop behind web_glue.dart (conditional export, same pattern as install_glue): openUrl, notification permission/posting and the Service Worker showNotification bridge. Stub is a no-op on native. Also applied repo-wide dart format (4 files). Rebuilt web/background.dart.js. Verified: analyze clean, web build OK, Android debug APK builds.
1 parent 4ed3b89 commit dbd5fe1

22 files changed

Lines changed: 3898 additions & 1782 deletions

‎.github/workflows/pages-demo.yml‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: pages-demo
2+
3+
on:
4+
push:
5+
branches: [feat/web-worker-messaging]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
11+
concurrency:
12+
group: pages-demo
13+
cancel-in-progress: true
14+
15+
jobs:
16+
build-and-deploy:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v7
20+
21+
- uses: subosito/flutter-action@v2
22+
with:
23+
channel: stable
24+
flutter-version-file: .fvmrc
25+
26+
- name: Bootstrap workspace (melos)
27+
run: |
28+
dart pub global activate melos
29+
melos bootstrap
30+
31+
- name: Build web demo (GitHub Pages subpath)
32+
working-directory: example
33+
run: flutter build web --release --base-href=/flutter_workmanager/ --web-resources-cdn
34+
35+
- name: Deploy to gh-pages
36+
working-directory: example
37+
env:
38+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
run: |
40+
# CanvasKit loads from gstatic (--web-resources-cdn); don't ship the
41+
# 26MB local bundle to gh-pages.
42+
rm -rf build/web/canvaskit
43+
cd build/web
44+
git init -q
45+
git config user.name "github-actions[bot]"
46+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
47+
git add -A
48+
git commit -q -m "deploy: web demo $(date -u +%Y%m%dT%H%M%SZ)"
49+
git push -f "https://x-access-token:${GITHUB_TOKEN}@github.com/FlutterCommunity/flutter_workmanager.git" HEAD:gh-pages

‎example/README.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,43 @@ The demo includes practical examples:
6161
## Documentation
6262

6363
For detailed guides and real-world use cases, visit: **[docs.page/fluttercommunity/flutter_workmanager →](https://docs.page/fluttercommunity/flutter_workmanager)**
64+
65+
## Web Demo (experimental)
66+
67+
Run `flutter run -d chrome` (or `flutter build web` and serve over HTTPS or
68+
localhost) to get the web-only demo. It demonstrates the experimental
69+
`workmanager_web` package with a simulated **weather watch** — no network
70+
and no API keys needed:
71+
72+
- **Two-way worker messaging** — the page and the background worker talk
73+
over `postMessage` (Chat tab). Tap "Watch Cardiff" and the worker
74+
streams simulated temperatures, alerting when it drops below the
75+
threshold; type any text and it echoes back. Page → worker via
76+
`sendMessageToWorker()`, worker → page via `sendToPage()` (surfaced on
77+
`WorkmanagerWeb.workerMessages`).
78+
- **Background tasks in a Web Worker** — register one-off / periodic tasks
79+
and watch them execute off the main thread (the UI stays responsive while
80+
the task's CPU loop runs). "Run check now" fires a one-off task; a
81+
periodic temperature check is registered automatically every 15 minutes.
82+
Results appear in the Task log tab.
83+
- **Service Worker execution + notifications** — install the PWA, allow
84+
notifications, close the tab, trigger Periodic Background Sync from
85+
DevTools and reopen: the task ran inside the Service Worker (compiled
86+
Dart dispatcher) and showed a notification while the tab was closed; the
87+
result is replayed from IndexedDB into the event log on the next load.
88+
89+
The demo has a **Guide tab** that walks through all of this step by step.
90+
91+
The background handler lives in `lib/web/background_tasks.dart` — a
92+
Flutter-free file compiled with plain `dart compile js` into
93+
`web/background.dart.js` (see `tool/build_web_background.sh`). Temperatures
94+
are simulated so the demo works offline; swap `_simulatedTemp()` for a real
95+
fetch to see the same pattern with live data.
96+
97+
## Key Files
98+
99+
- `lib/main.dart` - Main app with task scheduling UI
100+
- `lib/web/` - Web-only demo (Flutter-free dispatcher, worker chat, PWA install glue)
101+
- `lib/callback_dispatcher.dart` - Background task execution logic
102+
- `ios/Runner/AppDelegate.swift` - iOS background task registration
103+
- `ios/Runner/Info.plist` - iOS background modes configuration

‎example/lib/main.dart‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import 'package:permission_handler/permission_handler.dart';
1010
import 'package:shared_preferences/shared_preferences.dart';
1111
import 'package:workmanager/workmanager.dart';
1212

13-
import 'web/web_demo_page.dart';
13+
import 'web/web_app.dart';
1414

1515
void main() {
1616
if (kIsWeb) {

‎example/lib/web/app_theme.dart‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright 2024 The Flutter Workmanager Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/material.dart';
6+
7+
/// Shared high-contrast palette and theme for the web demo app (landing page
8+
/// and demo), so both stay visually consistent.
9+
abstract final class AppTheme {
10+
// High-contrast status colors, readable on white.
11+
static const Color okGreen = Color(0xFF1E8E3E);
12+
static const Color alertRed = Color(0xFFC5221F);
13+
static const Color warnOrange = Color(0xFFB06000);
14+
static const Color infoBlue = Color(0xFF174EA6);
15+
static const Color mutedGrey = Color(0xFF5B6670);
16+
17+
// Surfaces.
18+
static const Color primaryBlue = Color(0xFF0B57D0);
19+
static const Color primaryContainer = Color(0xFFD3E3FD);
20+
static const Color onPrimaryContainer = Color(0xFF001B3F);
21+
static const Color cardFill = Color(0xFFF1F4F8);
22+
static const Color borderGrey = Color(0xFFB0B8BF);
23+
static const Color surfaceHighest = Color(0xFFE1E5E8);
24+
static const Color textPrimary = Color(0xFF111111);
25+
static const Color textSecondary = Color(0xFF1F1F1F);
26+
27+
static ThemeData light() {
28+
return ThemeData(
29+
useMaterial3: true,
30+
colorScheme: ColorScheme.fromSeed(
31+
seedColor: primaryBlue,
32+
).copyWith(
33+
primary: primaryBlue,
34+
onPrimary: Colors.white,
35+
primaryContainer: primaryContainer,
36+
onPrimaryContainer: onPrimaryContainer,
37+
surface: Colors.white,
38+
onSurface: textPrimary,
39+
onSurfaceVariant: textSecondary,
40+
outline: mutedGrey,
41+
outlineVariant: borderGrey,
42+
surfaceContainerHighest: surfaceHighest,
43+
),
44+
textTheme: const TextTheme(
45+
titleLarge: TextStyle(
46+
fontSize: 20,
47+
fontWeight: FontWeight.w600,
48+
color: textPrimary,
49+
),
50+
titleSmall: TextStyle(
51+
fontSize: 15,
52+
fontWeight: FontWeight.w700,
53+
color: textPrimary,
54+
),
55+
bodyLarge: TextStyle(fontSize: 16, color: textPrimary),
56+
bodyMedium: TextStyle(fontSize: 15, color: textPrimary),
57+
bodySmall: TextStyle(fontSize: 14, color: textSecondary),
58+
labelLarge: TextStyle(fontSize: 14, color: textPrimary),
59+
),
60+
appBarTheme: const AppBarTheme(
61+
backgroundColor: primaryBlue,
62+
foregroundColor: Colors.white,
63+
titleTextStyle: TextStyle(
64+
fontSize: 20,
65+
fontWeight: FontWeight.w600,
66+
color: Colors.white,
67+
),
68+
),
69+
tabBarTheme: const TabBarThemeData(
70+
labelColor: Colors.white,
71+
unselectedLabelColor: primaryContainer,
72+
indicatorColor: Colors.white,
73+
labelStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
74+
unselectedLabelStyle: TextStyle(fontSize: 14),
75+
),
76+
dividerTheme: const DividerThemeData(color: borderGrey),
77+
);
78+
}
79+
}

‎example/lib/web/background_tasks.dart‎

Lines changed: 169 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,196 @@
77
// `web/background.dart.js` and executed both by the in-page Web Worker and by
88
// the Service Worker, neither of which can run the Flutter engine.
99

10+
import 'dart:async';
11+
1012
import 'package:workmanager_web/execution.dart';
1113

14+
import 'web_glue.dart';
15+
1216
/// Dispatcher used on the web: wired into the compiled worker bundle
1317
/// (`web/background.dart`) and passed to `WorkmanagerWeb().initialize(...)` on
1418
/// the page (fallback path).
1519
@pragma('vm:entry-point')
1620
void webCallbackDispatcher() {
1721
WorkmanagerExecution.instance.executeTask(handleWebBackgroundTask);
22+
WorkmanagerExecution.instance.messageHandler = handleWorkerMessage;
23+
}
24+
25+
// ---------------------------------------------------------------------------
26+
// Use case: a simulated "weather watch".
27+
//
28+
// The demo simulates a temperature feed so it stays self-contained (no
29+
// network, no API key). The same shape applies to any real background work:
30+
//
31+
// * the page sends a message to the worker -> messageHandler runs in
32+
// the Web Worker (off the main thread),
33+
// * the worker pushes updates back -> sendToPage surfaces them
34+
// on `WorkmanagerWeb.workerMessages`,
35+
// * background tasks (also while the page is closed, via the Service Worker)
36+
// run the same handler and their results are replayed into the event log.
37+
// ---------------------------------------------------------------------------
38+
39+
/// Simulated baseline temperature per city, in °C.
40+
const Map<String, double> _baseTemps = <String, double>{
41+
'cardiff': 11.0,
42+
'taipei': 26.0,
43+
};
44+
45+
Timer? _watchTimer;
46+
47+
/// Handler for free-form messages sent by the page with
48+
/// `WorkmanagerWeb().sendMessageToWorker(...)`.
49+
///
50+
/// Messages:
51+
/// * `{'op': 'watch', 'city': 'cardiff', 'threshold': 5.0}` — start pushing
52+
/// simulated temperatures every few seconds; stops itself when the
53+
/// temperature drops below [threshold].
54+
/// * `{'op': 'stop'}` — stop the current watch.
55+
/// * `{'op': 'check', 'city': ..., 'threshold': ...}` — run a one-off
56+
/// background check (same logic as the task path).
57+
/// * `{'op': 'text', 'text': ...}` — echo arbitrary text back.
58+
void handleWorkerMessage(Object? payload) {
59+
if (payload is! Map) {
60+
return;
61+
}
62+
final op = payload['op'];
63+
switch (op) {
64+
case 'watch':
65+
final city = (payload['city'] as String?)?.toLowerCase() ?? 'cardiff';
66+
final threshold = (payload['threshold'] as num?)?.toDouble();
67+
_watchTimer?.cancel();
68+
_post(<String, Object?>{
69+
'kind': 'watching',
70+
'city': city,
71+
'threshold': threshold,
72+
});
73+
_postTick(city, threshold);
74+
_watchTimer = Timer.periodic(
75+
const Duration(seconds: 3),
76+
(_) => _postTick(city, threshold),
77+
);
78+
case 'stop':
79+
_watchTimer?.cancel();
80+
_watchTimer = null;
81+
_post(<String, Object?>{'kind': 'stopped'});
82+
case 'check':
83+
// One-off background check on demand (same logic as the task path).
84+
final city = (payload['city'] as String?)?.toLowerCase() ?? 'cardiff';
85+
final threshold = (payload['threshold'] as num?)?.toDouble();
86+
_post(<String, Object?>{'kind': 'task-start', 'city': city});
87+
final tempC = _simulatedTemp(city);
88+
final below = threshold != null && tempC < threshold;
89+
_post(<String, Object?>{
90+
'kind': 'task-done',
91+
'city': city,
92+
'tempC': tempC,
93+
'below': below,
94+
});
95+
case 'text':
96+
_post(<String, Object?>{'kind': 'echo', 'text': payload['text']});
97+
}
98+
}
99+
100+
void _postTick(String city, double? threshold) {
101+
final tempC = _simulatedTemp(city);
102+
final below = threshold != null && tempC < threshold;
103+
_post(<String, Object?>{
104+
'kind': below ? 'alert' : 'tick',
105+
'city': city,
106+
'tempC': tempC,
107+
'threshold': threshold,
108+
});
109+
if (below) {
110+
_watchTimer?.cancel();
111+
_watchTimer = null;
112+
}
113+
}
114+
115+
/// Sends a free-form message back to the page (if a page is reachable).
116+
void _post(Object? payload) {
117+
WorkmanagerExecution.instance.sendToPage?.call(payload);
118+
}
119+
120+
/// Deterministic, time-varying simulated temperature: stable within a 30s
121+
/// bucket so consecutive ticks change, but the demo never needs the network.
122+
double _simulatedTemp(String city) {
123+
final base = _baseTemps[city] ?? 15.0;
124+
final bucket = DateTime.now().millisecondsSinceEpoch ~/ 30000;
125+
final hash = _hash('$city:$bucket');
126+
final wiggle = (hash % 1000) / 1000 * 0.10 - 0.05; // ±5%
127+
return base * (1 + wiggle);
128+
}
129+
130+
int _hash(String input) {
131+
var hash = 0;
132+
for (final codeUnit in input.codeUnits) {
133+
hash = (hash * 31 + codeUnit) & 0x7fffffff;
134+
}
135+
return hash;
18136
}
19137

20138
/// Pure-Dart background task handler.
21139
///
22-
/// The result is recorded by the runtime: when the page is open it appears in
23-
/// the status panel immediately; when the Service Worker ran the task while
24-
/// the page was closed, it is replayed on the next page load.
140+
/// With `inputData['city']` it behaves like a background "temperature
141+
/// check": it pushes progress messages to the page while running and returns
142+
/// the temperature + alert state as the task result. The result is recorded
143+
/// by the runtime: when the page is open it appears in the event log
144+
/// immediately; when the Service Worker ran the task while the page was
145+
/// closed, it is replayed on the next page load.
25146
Future<bool> handleWebBackgroundTask(
26147
String taskName,
27148
Map<String, dynamic>? inputData,
28149
) async {
150+
final input = inputData;
151+
if (input != null && input['fail'] == true) {
152+
return false;
153+
}
154+
final city = (input?['city'] as String?)?.toLowerCase() ?? 'cardiff';
155+
final threshold = (input?['threshold'] as num?)?.toDouble();
156+
29157
// A small CPU loop so the Web Worker's parallel execution is observable:
30158
// the UI stays responsive while this runs off the main thread.
31159
// ignore: unused_local_variable
32160
var checksum = 0;
33161
for (var i = 0; i < 2000000; i++) {
34162
checksum += i;
35163
}
36-
final input = inputData;
37-
return input != null && input['fail'] == true ? false : true;
164+
165+
_post(<String, Object?>{
166+
'kind': 'task-start',
167+
'city': city,
168+
'threshold': threshold,
169+
});
170+
final tempC = _simulatedTemp(city);
171+
final below = threshold != null && tempC < threshold;
172+
_post(<String, Object?>{
173+
'kind': 'task-done',
174+
'city': city,
175+
'tempC': tempC,
176+
'below': below,
177+
});
178+
_notify(
179+
below
180+
? '❄️ $city below the alert threshold'
181+
: 'Temperature check: ${_capitalize(city)}',
182+
'${tempC.toStringAsFixed(1)}°C — '
183+
'${below ? 'below the alert threshold' : 'all good'}',
184+
);
185+
// The task result itself stays a plain success/failure bool; the
186+
// temperature detail is delivered via the chat messages above.
187+
return true;
188+
}
189+
190+
/// Shows a browser notification when a background task finishes inside the
191+
/// Service Worker (i.e. while the page is closed). The dedicated Web Worker
192+
/// has no `registration`, so there the page shows the notification instead.
193+
void _notify(String title, String body) {
194+
showServiceWorkerNotification(title, body);
195+
}
196+
197+
String _capitalize(String input) {
198+
if (input.isEmpty) {
199+
return input;
200+
}
201+
return input[0].toUpperCase() + input.substring(1);
38202
}

0 commit comments

Comments
 (0)