From bc164ba43874f7b50ff13dc7c2ad0dcbf1e963a6 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Thu, 7 May 2020 16:26:14 -0700 Subject: [PATCH 1/7] [service worker] start work on service worker util package --- .../lib/flutter_service_worker.dart | 35 ++++++ .../lib/src/_io_impl.dart | 15 +++ .../lib/src/_web_impl.dart | 76 +++++++++++++ packages/flutter_service_worker/pubspec.yaml | 9 ++ .../flutter_service_worker/test/web_test.dart | 102 ++++++++++++++++++ 5 files changed, 237 insertions(+) create mode 100644 packages/flutter_service_worker/lib/flutter_service_worker.dart create mode 100644 packages/flutter_service_worker/lib/src/_io_impl.dart create mode 100644 packages/flutter_service_worker/lib/src/_web_impl.dart create mode 100644 packages/flutter_service_worker/pubspec.yaml create mode 100644 packages/flutter_service_worker/test/web_test.dart diff --git a/packages/flutter_service_worker/lib/flutter_service_worker.dart b/packages/flutter_service_worker/lib/flutter_service_worker.dart new file mode 100644 index 000000000000..77c529607cd4 --- /dev/null +++ b/packages/flutter_service_worker/lib/flutter_service_worker.dart @@ -0,0 +1,35 @@ +import 'src/_io_impl.dart' if (dart.library.js) 'src/_web_impl.dart'; + +/// An API for interacting with the service worker for application caching and +/// installation. +/// +/// On platforms other than the web, this delegates to a no-op implementation. +/// +/// See also: +/// +/// - https://web.dev/customize-install/ +abstract class ServiceWorkerApi { + /// A future that resolves when it is safe to call [showInstallPrompt]. + /// + /// If the application is not compatible with a service worker installation, + /// for example by running on http instead of https, then this future + /// will never resolve. + /// + /// Not all browsers + Future get installPromptReady; + + /// Trigger a prompt that allows users to install their application to the + /// device/home screen location. + /// + /// Returns a boolean that indicates whether the installation prompt was + /// accepted. + /// + /// Throws a [StateError] if this function is called before [installPromptReady] + /// resolves. + Future showInstallPrompt(); +} + +/// The singleton [ServiceWorkerApi] instance. +ServiceWorkerApi get serviceWorkerApi => + _serviceWorkerApi ??= ServiceWorkerImpl(); +ServiceWorkerApi _serviceWorkerApi; diff --git a/packages/flutter_service_worker/lib/src/_io_impl.dart b/packages/flutter_service_worker/lib/src/_io_impl.dart new file mode 100644 index 000000000000..422ab8cf33c0 --- /dev/null +++ b/packages/flutter_service_worker/lib/src/_io_impl.dart @@ -0,0 +1,15 @@ +import 'dart:async'; + +import '../flutter_service_worker.dart'; + +/// An unsupported implementation of the [ServiceWorkerApi] for non-web +/// platforms. +class ServiceWorkerImpl extends ServiceWorkerApi { + @override + Future get installPromptReady => Completer().future; + + @override + Future showInstallPrompt() { + throw UnsupportedError('showInstallPrompt is only supported on the web.'); + } +} diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart new file mode 100644 index 000000000000..1cb89f67e757 --- /dev/null +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -0,0 +1,76 @@ +@JS() +library _web_impl; + +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:js/js.dart'; + +import '../flutter_service_worker.dart'; + +const String _kPromptEvent = 'beforeinstallprompt'; + +/// An implementation of the [ServiceWorkerApi] that delegates to the JS Window +/// object. +class ServiceWorkerImpl extends ServiceWorkerApi { + /// Create a new [ServiceWorkerImpl]. + ServiceWorkerImpl([@visibleForTesting Window overrideWindow]) { + (overrideWindow ?? window).addEventListener(_kPromptEvent, allowInterop((Event event) { + if (_installPromptReady.isCompleted) { + return; + } + event.preventDefault(); + _installPrompt = event; + _installPromptReady.complete(); + })); + } + + final Completer _installPromptReady = Completer(); + Event _installPrompt; + + @override + Future get installPromptReady => _installPromptReady.future; + + @override + Future showInstallPrompt() async { + assert(_installPrompt != null, + 'The installation future needs to resolve before acceptInstallPrompt can be called'); + if (_installPrompt == null) { + throw StateError('missing installPrompt'); + } + _installPrompt.prompt(); + final String result = await _installPrompt.userChoice; + return result == 'accepted'; + } +} + +/// JS interop for Window access. +@visibleForTesting +@JS() +external Window get window; + +/// JS interop for Window access. +@visibleForTesting +@JS() +class Window { + /// JS interop for Event access. + @visibleForTesting + external void addEventListener(String name, void Function(Event) handler); +} + +/// JS interop for Event access. +@visibleForTesting +@JS() +class Event { + /// JS interop for Event access. + @visibleForTesting + external void preventDefault(); + + /// JS interop for Event access. + @visibleForTesting + external void prompt(); + + /// JS interop for Event access. + @visibleForTesting + external Future get userChoice; +} diff --git a/packages/flutter_service_worker/pubspec.yaml b/packages/flutter_service_worker/pubspec.yaml new file mode 100644 index 000000000000..0432380a8436 --- /dev/null +++ b/packages/flutter_service_worker/pubspec.yaml @@ -0,0 +1,9 @@ +name: flutter_service_worker + +dependencies: + js: 0.6.1+1 + meta: 1.1.8 + +dev_dependencies: + test: 1.14.3 + mockito: 4.1.1 \ No newline at end of file diff --git a/packages/flutter_service_worker/test/web_test.dart b/packages/flutter_service_worker/test/web_test.dart new file mode 100644 index 000000000000..af53a64b24d0 --- /dev/null +++ b/packages/flutter_service_worker/test/web_test.dart @@ -0,0 +1,102 @@ +@TestOn('chrome') +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; +import 'package:flutter_service_worker/src/_web_impl.dart'; + +void main() { + test('listens to the beforeinstallprompt on the window', () { + final MockWindow window = MockWindow(); + ServiceWorkerImpl(window); + + verify(window.addEventListener('beforeinstallprompt', any)).called(1); + }); + + test('resolves the installPromptReady when an Event is received', () async { + final MockWindow window = MockWindow(); + final MockEvent event = MockEvent(); + final ServiceWorkerImpl api = ServiceWorkerImpl(window); + + final void Function(Event) callback = + verify(window.addEventListener('beforeinstallprompt', captureAny)) + .captured + .first as void Function(Event); + callback(event); + + verify(event.preventDefault()).called(1); + await expectLater(api.installPromptReady, completes); + }); + + test( + 'resolves the installPromptReady when an Event is received multiple times', + () async { + final MockWindow window = MockWindow(); + final MockEvent event = MockEvent(); + final ServiceWorkerImpl api = ServiceWorkerImpl(window); + + final void Function(Event) callback = + verify(window.addEventListener('beforeinstallprompt', captureAny)) + .captured + .first as void Function(Event); + callback(event); + callback(event); + + verify(event.preventDefault()).called(1); + await expectLater(api.installPromptReady, completes); + }); + + test( + 'throws an Error if showInstallPrompt is called before installPromptReady resolves', + () { + final MockWindow window = MockWindow(); + final ServiceWorkerImpl api = ServiceWorkerImpl(window); + + // Could be either assertion or StateError depending on mode. + expect(() => api.showInstallPrompt(), throwsA(isA())); + }); + + test('Will invoke the install prompt and return success', () async { + final MockWindow window = MockWindow(); + final MockEvent event = MockEvent(); + final ServiceWorkerImpl api = ServiceWorkerImpl(window); + + final void Function(Event) callback = + verify(window.addEventListener('beforeinstallprompt', captureAny)) + .captured + .first as void Function(Event); + callback(event); + + await api.installPromptReady; + + when(event.prompt()).thenAnswer((_) async {}); + when(event.userChoice).thenAnswer((_) async { + return 'accepted'; + }); + + expect(await api.showInstallPrompt(), true); + }); + + test('Will invoke the install prompt and return failure', () async { + final MockWindow window = MockWindow(); + final MockEvent event = MockEvent(); + final ServiceWorkerImpl api = ServiceWorkerImpl(window); + + final void Function(Event) callback = + verify(window.addEventListener('beforeinstallprompt', captureAny)) + .captured + .first as void Function(Event); + callback(event); + + await api.installPromptReady; + + when(event.prompt()).thenAnswer((_) async {}); + when(event.userChoice).thenAnswer((_) async { + return 'something else'; + }); + + expect(await api.showInstallPrompt(), false); + }); +} + +class MockWindow extends Mock implements Window {} + +class MockEvent extends Mock implements Event {} From 3c15a0b63aa4f1df937bf73ae78c9752262f660e Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Thu, 7 May 2020 16:38:53 -0700 Subject: [PATCH 2/7] Add metadata and license info --- .../flutter_service_worker/lib/flutter_service_worker.dart | 4 ++++ packages/flutter_service_worker/lib/src/_io_impl.dart | 4 ++++ packages/flutter_service_worker/lib/src/_web_impl.dart | 4 ++++ packages/flutter_service_worker/pubspec.yaml | 7 ++++++- packages/flutter_service_worker/test/web_test.dart | 4 ++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/flutter_service_worker/lib/flutter_service_worker.dart b/packages/flutter_service_worker/lib/flutter_service_worker.dart index 77c529607cd4..cffddf7fa3a4 100644 --- a/packages/flutter_service_worker/lib/flutter_service_worker.dart +++ b/packages/flutter_service_worker/lib/flutter_service_worker.dart @@ -1,3 +1,7 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + import 'src/_io_impl.dart' if (dart.library.js) 'src/_web_impl.dart'; /// An API for interacting with the service worker for application caching and diff --git a/packages/flutter_service_worker/lib/src/_io_impl.dart b/packages/flutter_service_worker/lib/src/_io_impl.dart index 422ab8cf33c0..c9901953de4b 100644 --- a/packages/flutter_service_worker/lib/src/_io_impl.dart +++ b/packages/flutter_service_worker/lib/src/_io_impl.dart @@ -1,3 +1,7 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + import 'dart:async'; import '../flutter_service_worker.dart'; diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart index 1cb89f67e757..293bd6f670e3 100644 --- a/packages/flutter_service_worker/lib/src/_web_impl.dart +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -1,3 +1,7 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + @JS() library _web_impl; diff --git a/packages/flutter_service_worker/pubspec.yaml b/packages/flutter_service_worker/pubspec.yaml index 0432380a8436..9ce99feb8bc8 100644 --- a/packages/flutter_service_worker/pubspec.yaml +++ b/packages/flutter_service_worker/pubspec.yaml @@ -1,4 +1,9 @@ name: flutter_service_worker +description: Flutter package for integrating with the web Service Worker +homepage: https://github.com/flutter/packages/tree/master/packages/flutter_service_worker +version: 0.0.1 +authors: + - Jonah Williams dependencies: js: 0.6.1+1 @@ -6,4 +11,4 @@ dependencies: dev_dependencies: test: 1.14.3 - mockito: 4.1.1 \ No newline at end of file + mockito: 4.1.1 diff --git a/packages/flutter_service_worker/test/web_test.dart b/packages/flutter_service_worker/test/web_test.dart index af53a64b24d0..17d3b8bb7f19 100644 --- a/packages/flutter_service_worker/test/web_test.dart +++ b/packages/flutter_service_worker/test/web_test.dart @@ -1,3 +1,7 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + @TestOn('chrome') import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; From 3bad447d349a1eb576a6b7ac4b99a342c4420636 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Fri, 8 May 2020 17:17:21 -0700 Subject: [PATCH 3/7] more work on API --- packages/flutter_service_worker/README.md | 67 +++++++++++ .../lib/flutter_service_worker.dart | 27 ++++- .../lib/src/_io_impl.dart | 16 +++ .../lib/src/_web_impl.dart | 111 ++++++++++++------ packages/flutter_service_worker/pubspec.yaml | 8 +- .../flutter_service_worker/test/web_test.dart | 90 +------------- 6 files changed, 187 insertions(+), 132 deletions(-) create mode 100644 packages/flutter_service_worker/README.md diff --git a/packages/flutter_service_worker/README.md b/packages/flutter_service_worker/README.md new file mode 100644 index 000000000000..14047aba0f0b --- /dev/null +++ b/packages/flutter_service_worker/README.md @@ -0,0 +1,67 @@ +## Flutter Service Worker + +A collection of utility APIs for interacting with the Flutter service worker. + + +### Setup + +The `init` method should be called in main to bootstrap the service worker API. This is safe +to call on non-web platforms. + +```dart +void main() { + serviceWorkerApi.init(); + runApp(MyApp()); +} +``` + + +### Detecting a new version + +A service worker will cache the old application until the new application is downloaded and ready. To be notified when this occurs, listen to the `newVersionReady` future. You can then use `skipWaiting()` to +force-load the new version. + +```dart + +serviceWorkerApi.newVersionReady.whenComplete(() { + showNewVersionDialog().then((bool yes) { + if (yes) { + serviceWorkerApi.skipWaiting(); + } + }) +}); + +``` + +### Prompting for user install + +Some browsers allow displaying a notification to install the web application to home screens or start menus. This can be done by waiting for `installPromptReady` to resolve. Once this is done, `showInstallPrompt()` can be called in response to user input, which will display a prompt. + +```dart +serviceWorkerApi.installPromptReady.whenComplete(() { + showVersionInstallDialog().then((bool yes) { + if (yes) { + serviceWorkerApi.showInstallPrompt(); + } + }); +}) + +``` + + +### Offline cache + +By default, the Flutter service worker will cache an only the application shell upfront, other resources are cached on-demand. The `downloadOffline` method will force the service worker to eagerly cache all resources to +prepare the application for offline support. + + +```dart +MaterialButton( + child: Text('DOWNLOAD OFFLINE'), + onPressed: () async { + serviceWorkerApi.downloadOffline().whenComplete(() { + showOfflineDownloadComplete(); + }); + } +) +``` \ No newline at end of file diff --git a/packages/flutter_service_worker/lib/flutter_service_worker.dart b/packages/flutter_service_worker/lib/flutter_service_worker.dart index cffddf7fa3a4..5478bcd102d5 100644 --- a/packages/flutter_service_worker/lib/flutter_service_worker.dart +++ b/packages/flutter_service_worker/lib/flutter_service_worker.dart @@ -13,12 +13,21 @@ import 'src/_io_impl.dart' if (dart.library.js) 'src/_web_impl.dart'; /// /// - https://web.dev/customize-install/ abstract class ServiceWorkerApi { + /// Initialize the service worker API. + /// + /// This method should be called immediate in main, before calling + /// [runApp]. + void init(); + /// A future that resolves when it is safe to call [showInstallPrompt]. /// /// If the application is not compatible with a service worker installation, /// for example by running on http instead of https, then this future /// will never resolve. /// + /// This installation prompt event is currently only supported on Chrome. + /// On other browsers this future will never resolve. + /// /// Not all browsers Future get installPromptReady; @@ -28,9 +37,25 @@ abstract class ServiceWorkerApi { /// Returns a boolean that indicates whether the installation prompt was /// accepted. /// + /// This installation prompt event is currently only supported on Chrome. + /// On other browsers [installPromptReady] will never resolve. + /// /// Throws a [StateError] if this function is called before [installPromptReady] - /// resolves. + /// resolves, or if it is not called in response to a user initiated gesture. Future showInstallPrompt(); + + /// A future that resolves when a new version of the application is ready. + Future get newVersionReady; + + /// If a new version is available, skip a waiting period and force the browser + /// to reload. + /// + /// This operation is disruptive and should only be called if there are no + /// other user activities or in response to a prompt. + Future skipWaiting(); + + /// For the service worker to cache all resources files for offline use. + Future downloadOffline(); } /// The singleton [ServiceWorkerApi] instance. diff --git a/packages/flutter_service_worker/lib/src/_io_impl.dart b/packages/flutter_service_worker/lib/src/_io_impl.dart index c9901953de4b..d87991f68303 100644 --- a/packages/flutter_service_worker/lib/src/_io_impl.dart +++ b/packages/flutter_service_worker/lib/src/_io_impl.dart @@ -12,8 +12,24 @@ class ServiceWorkerImpl extends ServiceWorkerApi { @override Future get installPromptReady => Completer().future; + @override + void init() {} + @override Future showInstallPrompt() { throw UnsupportedError('showInstallPrompt is only supported on the web.'); } + + @override + Future get newVersionReady => Completer().future; + + @override + Future skipWaiting() { + throw UnsupportedError('skipWaiting is only supported on the web.'); + } + + @override + Future downloadOffline() { + throw UnsupportedError('downloadOffline is only supported on the web.'); + } } diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart index 293bd6f670e3..712c7fc28449 100644 --- a/packages/flutter_service_worker/lib/src/_web_impl.dart +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -6,31 +6,56 @@ library _web_impl; import 'dart:async'; +import 'dart:html'; +import 'dart:js'; +import 'dart:js_util'; -import 'package:meta/meta.dart'; import 'package:js/js.dart'; import '../flutter_service_worker.dart'; -const String _kPromptEvent = 'beforeinstallprompt'; - /// An implementation of the [ServiceWorkerApi] that delegates to the JS Window /// object. class ServiceWorkerImpl extends ServiceWorkerApi { - /// Create a new [ServiceWorkerImpl]. - ServiceWorkerImpl([@visibleForTesting Window overrideWindow]) { - (overrideWindow ?? window).addEventListener(_kPromptEvent, allowInterop((Event event) { + @override + void init() { + window.addEventListener('beforeinstallprompt', allowInterop((Object event) { if (_installPromptReady.isCompleted) { return; } - event.preventDefault(); - _installPrompt = event; + _installPrompt = JsObject.fromBrowserObject(event) + ..callMethod('preventDefault'); _installPromptReady.complete(); })); + window.navigator.serviceWorker.ready + .then((ServiceWorkerRegistration registration) { + if (registration.waiting != null) { + if (!_installPromptReady.isCompleted) { + _newVersionReady.complete(); + } + } + if (registration.installing != null) { + _handleInstall(registration.installing); + } + registration.addEventListener('updatefound', (_) { + _handleInstall(registration.installing); + }); + }); + } + + void _handleInstall(ServiceWorker serviceWorker) { + serviceWorker.addEventListener('statechange', (_) { + if (serviceWorker.state == 'installed') { + if (!_installPromptReady.isCompleted) { + _installPromptReady.complete(); + } + } + }); } final Completer _installPromptReady = Completer(); - Event _installPrompt; + final Completer _newVersionReady = Completer(); + JsObject _installPrompt; @override Future get installPromptReady => _installPromptReady.future; @@ -42,39 +67,47 @@ class ServiceWorkerImpl extends ServiceWorkerApi { if (_installPrompt == null) { throw StateError('missing installPrompt'); } - _installPrompt.prompt(); - final String result = await _installPrompt.userChoice; + try { + await promiseToFuture(_installPrompt.callMethod('prompt')); + } catch (err) { + throw StateError(err.toString()); + } + final String result = + await promiseToFuture(getProperty(_installPrompt, 'userChoice')); return result == 'accepted'; } -} - -/// JS interop for Window access. -@visibleForTesting -@JS() -external Window get window; - -/// JS interop for Window access. -@visibleForTesting -@JS() -class Window { - /// JS interop for Event access. - @visibleForTesting - external void addEventListener(String name, void Function(Event) handler); -} -/// JS interop for Event access. -@visibleForTesting -@JS() -class Event { - /// JS interop for Event access. - @visibleForTesting - external void preventDefault(); + @override + Future get newVersionReady => _newVersionReady.future; - /// JS interop for Event access. - @visibleForTesting - external void prompt(); + @override + Future skipWaiting() async { + final ServiceWorkerRegistration registration = + await window.navigator.serviceWorker.ready; + bool refreshing = false; + registration.active.addEventListener('controllerchange', (_) { + if (refreshing) { + return; + } + refreshing = true; + window.location.reload(); + }); + registration.active.postMessage({'message': 'skipWaiting'}); + } - /// JS interop for Event access. - @visibleForTesting - external Future get userChoice; + @override + Future downloadOffline() async { + final ServiceWorkerRegistration registration = + await window.navigator.serviceWorker.ready; + final Completer completer = Completer(); + registration.active.addEventListener('message', (Event event) { + if (completer.isCompleted) { + return; + } + completer.complete(); + }); + registration.active + .postMessage({'message': 'downloadOffline'}); + await completer.future; + } } diff --git a/packages/flutter_service_worker/pubspec.yaml b/packages/flutter_service_worker/pubspec.yaml index 9ce99feb8bc8..13f8c73ca6c2 100644 --- a/packages/flutter_service_worker/pubspec.yaml +++ b/packages/flutter_service_worker/pubspec.yaml @@ -5,10 +5,12 @@ version: 0.0.1 authors: - Jonah Williams +environment: + # The pub client defaults to an <2.0.0 sdk constraint which we need to explicitly overwrite. + sdk: ">=2.8.0 <3.0.0" + dependencies: - js: 0.6.1+1 meta: 1.1.8 dev_dependencies: - test: 1.14.3 - mockito: 4.1.1 + test: 1.14.3 \ No newline at end of file diff --git a/packages/flutter_service_worker/test/web_test.dart b/packages/flutter_service_worker/test/web_test.dart index 17d3b8bb7f19..91c079469475 100644 --- a/packages/flutter_service_worker/test/web_test.dart +++ b/packages/flutter_service_worker/test/web_test.dart @@ -3,104 +3,16 @@ // found in the LICENSE file. @TestOn('chrome') -import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; import 'package:flutter_service_worker/src/_web_impl.dart'; void main() { - test('listens to the beforeinstallprompt on the window', () { - final MockWindow window = MockWindow(); - ServiceWorkerImpl(window); - - verify(window.addEventListener('beforeinstallprompt', any)).called(1); - }); - - test('resolves the installPromptReady when an Event is received', () async { - final MockWindow window = MockWindow(); - final MockEvent event = MockEvent(); - final ServiceWorkerImpl api = ServiceWorkerImpl(window); - - final void Function(Event) callback = - verify(window.addEventListener('beforeinstallprompt', captureAny)) - .captured - .first as void Function(Event); - callback(event); - - verify(event.preventDefault()).called(1); - await expectLater(api.installPromptReady, completes); - }); - - test( - 'resolves the installPromptReady when an Event is received multiple times', - () async { - final MockWindow window = MockWindow(); - final MockEvent event = MockEvent(); - final ServiceWorkerImpl api = ServiceWorkerImpl(window); - - final void Function(Event) callback = - verify(window.addEventListener('beforeinstallprompt', captureAny)) - .captured - .first as void Function(Event); - callback(event); - callback(event); - - verify(event.preventDefault()).called(1); - await expectLater(api.installPromptReady, completes); - }); - test( 'throws an Error if showInstallPrompt is called before installPromptReady resolves', () { - final MockWindow window = MockWindow(); - final ServiceWorkerImpl api = ServiceWorkerImpl(window); + final ServiceWorkerImpl api = ServiceWorkerImpl()..init(); // Could be either assertion or StateError depending on mode. expect(() => api.showInstallPrompt(), throwsA(isA())); }); - - test('Will invoke the install prompt and return success', () async { - final MockWindow window = MockWindow(); - final MockEvent event = MockEvent(); - final ServiceWorkerImpl api = ServiceWorkerImpl(window); - - final void Function(Event) callback = - verify(window.addEventListener('beforeinstallprompt', captureAny)) - .captured - .first as void Function(Event); - callback(event); - - await api.installPromptReady; - - when(event.prompt()).thenAnswer((_) async {}); - when(event.userChoice).thenAnswer((_) async { - return 'accepted'; - }); - - expect(await api.showInstallPrompt(), true); - }); - - test('Will invoke the install prompt and return failure', () async { - final MockWindow window = MockWindow(); - final MockEvent event = MockEvent(); - final ServiceWorkerImpl api = ServiceWorkerImpl(window); - - final void Function(Event) callback = - verify(window.addEventListener('beforeinstallprompt', captureAny)) - .captured - .first as void Function(Event); - callback(event); - - await api.installPromptReady; - - when(event.prompt()).thenAnswer((_) async {}); - when(event.userChoice).thenAnswer((_) async { - return 'something else'; - }); - - expect(await api.showInstallPrompt(), false); - }); } - -class MockWindow extends Mock implements Window {} - -class MockEvent extends Mock implements Event {} From 1e629514cc9787a00eb845435d926968b166423e Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Fri, 8 May 2020 17:19:38 -0700 Subject: [PATCH 4/7] remove JS --- packages/flutter_service_worker/lib/src/_web_impl.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart index 712c7fc28449..9bfe74e4d735 100644 --- a/packages/flutter_service_worker/lib/src/_web_impl.dart +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -@JS() library _web_impl; import 'dart:async'; @@ -10,8 +9,6 @@ import 'dart:html'; import 'dart:js'; import 'dart:js_util'; -import 'package:js/js.dart'; - import '../flutter_service_worker.dart'; /// An implementation of the [ServiceWorkerApi] that delegates to the JS Window From 6494c2fea394f0058d722754953c09d048907230 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Wed, 17 Jun 2020 14:59:12 -0700 Subject: [PATCH 5/7] cleanups --- packages/flutter_service_worker/CHANGELOG.md | 3 ++ packages/flutter_service_worker/LICENSE | 28 +++++++++++++++++++ .../lib/flutter_service_worker.dart | 4 +-- .../lib/src/_web_impl.dart | 5 ++-- 4 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 packages/flutter_service_worker/CHANGELOG.md create mode 100644 packages/flutter_service_worker/LICENSE diff --git a/packages/flutter_service_worker/CHANGELOG.md b/packages/flutter_service_worker/CHANGELOG.md new file mode 100644 index 000000000000..d0bd041d0ff6 --- /dev/null +++ b/packages/flutter_service_worker/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* Initial release. diff --git a/packages/flutter_service_worker/LICENSE b/packages/flutter_service_worker/LICENSE new file mode 100644 index 000000000000..7ecdec034b6d --- /dev/null +++ b/packages/flutter_service_worker/LICENSE @@ -0,0 +1,28 @@ +Copyright 2020 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_service_worker/lib/flutter_service_worker.dart b/packages/flutter_service_worker/lib/flutter_service_worker.dart index 5478bcd102d5..d55f355542dd 100644 --- a/packages/flutter_service_worker/lib/flutter_service_worker.dart +++ b/packages/flutter_service_worker/lib/flutter_service_worker.dart @@ -11,11 +11,11 @@ import 'src/_io_impl.dart' if (dart.library.js) 'src/_web_impl.dart'; /// /// See also: /// -/// - https://web.dev/customize-install/ +/// * https://web.dev/customize-install/ abstract class ServiceWorkerApi { /// Initialize the service worker API. /// - /// This method should be called immediate in main, before calling + /// This method should be called immediately in main, before calling /// [runApp]. void init(); diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart index 9bfe74e4d735..ae80f0904bfc 100644 --- a/packages/flutter_service_worker/lib/src/_web_impl.dart +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -89,7 +89,7 @@ class ServiceWorkerImpl extends ServiceWorkerApi { refreshing = true; window.location.reload(); }); - registration.active.postMessage({'message': 'skipWaiting'}); + registration.active.postMessage('skipWaiting'); } @override @@ -103,8 +103,7 @@ class ServiceWorkerImpl extends ServiceWorkerApi { } completer.complete(); }); - registration.active - .postMessage({'message': 'downloadOffline'}); + registration.active.postMessage('downloadOffline'); await completer.future; } } From 72619f47e7f164b996b5ce8256281529b27d9966 Mon Sep 17 00:00:00 2001 From: jonahwilliams Date: Mon, 14 Sep 2020 12:22:22 -0700 Subject: [PATCH 6/7] updates for latest API versions --- packages/flutter_service_worker/README.md | 20 +------------ .../lib/flutter_service_worker.dart | 12 +++----- .../lib/src/_io_impl.dart | 9 ++---- .../lib/src/_web_impl.dart | 29 ++----------------- packages/flutter_service_worker/pubspec.yaml | 3 -- 5 files changed, 9 insertions(+), 64 deletions(-) diff --git a/packages/flutter_service_worker/README.md b/packages/flutter_service_worker/README.md index 14047aba0f0b..10590446ee00 100644 --- a/packages/flutter_service_worker/README.md +++ b/packages/flutter_service_worker/README.md @@ -26,7 +26,7 @@ force-load the new version. serviceWorkerApi.newVersionReady.whenComplete(() { showNewVersionDialog().then((bool yes) { if (yes) { - serviceWorkerApi.skipWaiting(); + serviceWorkerApi.reload(); } }) }); @@ -47,21 +47,3 @@ serviceWorkerApi.installPromptReady.whenComplete(() { }) ``` - - -### Offline cache - -By default, the Flutter service worker will cache an only the application shell upfront, other resources are cached on-demand. The `downloadOffline` method will force the service worker to eagerly cache all resources to -prepare the application for offline support. - - -```dart -MaterialButton( - child: Text('DOWNLOAD OFFLINE'), - onPressed: () async { - serviceWorkerApi.downloadOffline().whenComplete(() { - showOfflineDownloadComplete(); - }); - } -) -``` \ No newline at end of file diff --git a/packages/flutter_service_worker/lib/flutter_service_worker.dart b/packages/flutter_service_worker/lib/flutter_service_worker.dart index d55f355542dd..5edd1f21cf90 100644 --- a/packages/flutter_service_worker/lib/flutter_service_worker.dart +++ b/packages/flutter_service_worker/lib/flutter_service_worker.dart @@ -47,15 +47,11 @@ abstract class ServiceWorkerApi { /// A future that resolves when a new version of the application is ready. Future get newVersionReady; - /// If a new version is available, skip a waiting period and force the browser - /// to reload. + /// Reload the applicaiton. /// - /// This operation is disruptive and should only be called if there are no - /// other user activities or in response to a prompt. - Future skipWaiting(); - - /// For the service worker to cache all resources files for offline use. - Future downloadOffline(); + /// This can be used after [newVersionReady] completes to refresh the page + /// with the new application loaded. + void reload(); } /// The singleton [ServiceWorkerApi] instance. diff --git a/packages/flutter_service_worker/lib/src/_io_impl.dart b/packages/flutter_service_worker/lib/src/_io_impl.dart index d87991f68303..ea88840d50d5 100644 --- a/packages/flutter_service_worker/lib/src/_io_impl.dart +++ b/packages/flutter_service_worker/lib/src/_io_impl.dart @@ -24,12 +24,7 @@ class ServiceWorkerImpl extends ServiceWorkerApi { Future get newVersionReady => Completer().future; @override - Future skipWaiting() { - throw UnsupportedError('skipWaiting is only supported on the web.'); - } - - @override - Future downloadOffline() { - throw UnsupportedError('downloadOffline is only supported on the web.'); + void reload() { + throw UnsupportedError('reload is only supported on the web.'); } } diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart index ae80f0904bfc..3198b2a52a6a 100644 --- a/packages/flutter_service_worker/lib/src/_web_impl.dart +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -78,32 +78,7 @@ class ServiceWorkerImpl extends ServiceWorkerApi { Future get newVersionReady => _newVersionReady.future; @override - Future skipWaiting() async { - final ServiceWorkerRegistration registration = - await window.navigator.serviceWorker.ready; - bool refreshing = false; - registration.active.addEventListener('controllerchange', (_) { - if (refreshing) { - return; - } - refreshing = true; - window.location.reload(); - }); - registration.active.postMessage('skipWaiting'); - } - - @override - Future downloadOffline() async { - final ServiceWorkerRegistration registration = - await window.navigator.serviceWorker.ready; - final Completer completer = Completer(); - registration.active.addEventListener('message', (Event event) { - if (completer.isCompleted) { - return; - } - completer.complete(); - }); - registration.active.postMessage('downloadOffline'); - await completer.future; + Future reload() { + window.location.reload(); } } diff --git a/packages/flutter_service_worker/pubspec.yaml b/packages/flutter_service_worker/pubspec.yaml index 13f8c73ca6c2..6478e34e1ffc 100644 --- a/packages/flutter_service_worker/pubspec.yaml +++ b/packages/flutter_service_worker/pubspec.yaml @@ -9,8 +9,5 @@ environment: # The pub client defaults to an <2.0.0 sdk constraint which we need to explicitly overwrite. sdk: ">=2.8.0 <3.0.0" -dependencies: - meta: 1.1.8 - dev_dependencies: test: 1.14.3 \ No newline at end of file From f726de5beb99c09ed27b28eee6d2ad8a9948be36 Mon Sep 17 00:00:00 2001 From: jonahwilliams Date: Fri, 9 Oct 2020 16:10:28 -0700 Subject: [PATCH 7/7] address review comments and refactor API --- packages/flutter_service_worker/README.md | 20 ++++------- .../lib/flutter_service_worker.dart | 33 +++++++++-------- .../lib/src/_io_impl.dart | 14 ++------ .../lib/src/_web_impl.dart | 35 ++++++++++++------- .../flutter_service_worker/test/web_test.dart | 18 ---------- 5 files changed, 50 insertions(+), 70 deletions(-) delete mode 100644 packages/flutter_service_worker/test/web_test.dart diff --git a/packages/flutter_service_worker/README.md b/packages/flutter_service_worker/README.md index 10590446ee00..12b30db8c47a 100644 --- a/packages/flutter_service_worker/README.md +++ b/packages/flutter_service_worker/README.md @@ -2,7 +2,6 @@ A collection of utility APIs for interacting with the Flutter service worker. - ### Setup The `init` method should be called in main to bootstrap the service worker API. This is safe @@ -15,33 +14,28 @@ void main() { } ``` - ### Detecting a new version -A service worker will cache the old application until the new application is downloaded and ready. To be notified when this occurs, listen to the `newVersionReady` future. You can then use `skipWaiting()` to -force-load the new version. +A service worker will cache the old application until the new application is downloaded and ready. To be notified when this occurs, listen for the `newVersionReady` future to resolve to a updater. You can then use `updater.reload()` to refresh to the new version. ```dart -serviceWorkerApi.newVersionReady.whenComplete(() { - showNewVersionDialog().then((bool yes) { - if (yes) { - serviceWorkerApi.reload(); - } - }) +serviceWorkerApi.newVersionReady.then((updater) { + print('Updating to new version'); + updater.reload(); }); ``` ### Prompting for user install -Some browsers allow displaying a notification to install the web application to home screens or start menus. This can be done by waiting for `installPromptReady` to resolve. Once this is done, `showInstallPrompt()` can be called in response to user input, which will display a prompt. +Some browsers allow displaying a notification to install the web application to home screens or start menus. This can be done by waiting for `installPromptReady` to resolve with an `installer`. Once this is done, `installer.showInstallPrompt()` can be called in response to user input, which will display a prompt. ```dart -serviceWorkerApi.installPromptReady.whenComplete(() { +serviceWorkerApi.installPromptReady.then((installer) { showVersionInstallDialog().then((bool yes) { if (yes) { - serviceWorkerApi.showInstallPrompt(); + installer.showInstallPrompt(); } }); }) diff --git a/packages/flutter_service_worker/lib/flutter_service_worker.dart b/packages/flutter_service_worker/lib/flutter_service_worker.dart index 5edd1f21cf90..6b6f670de8a9 100644 --- a/packages/flutter_service_worker/lib/flutter_service_worker.dart +++ b/packages/flutter_service_worker/lib/flutter_service_worker.dart @@ -19,7 +19,8 @@ abstract class ServiceWorkerApi { /// [runApp]. void init(); - /// A future that resolves when it is safe to call [showInstallPrompt]. + /// A future that resolves when the browser will allow an "add to home screen" + /// prompt to be shown. /// /// If the application is not compatible with a service worker installation, /// for example by running on http instead of https, then this future @@ -27,10 +28,24 @@ abstract class ServiceWorkerApi { /// /// This installation prompt event is currently only supported on Chrome. /// On other browsers this future will never resolve. + Future get installPromptReady; + + /// A future that resolves when a new version of the application is ready. + Future get newVersionReady; +} + +/// An handler provided when a new service worker has downloaded and activated. +abstract class UpdateResponse { + /// Reload the application. /// - /// Not all browsers - Future get installPromptReady; + /// This can be used after [newVersionReady] completes to refresh the page + /// with the new application loaded. + void reload(); +} +/// A handler provided when the browser indicates this application is +/// permitted to show an install prompt. +abstract class InstallResponse { /// Trigger a prompt that allows users to install their application to the /// device/home screen location. /// @@ -39,19 +54,7 @@ abstract class ServiceWorkerApi { /// /// This installation prompt event is currently only supported on Chrome. /// On other browsers [installPromptReady] will never resolve. - /// - /// Throws a [StateError] if this function is called before [installPromptReady] - /// resolves, or if it is not called in response to a user initiated gesture. Future showInstallPrompt(); - - /// A future that resolves when a new version of the application is ready. - Future get newVersionReady; - - /// Reload the applicaiton. - /// - /// This can be used after [newVersionReady] completes to refresh the page - /// with the new application loaded. - void reload(); } /// The singleton [ServiceWorkerApi] instance. diff --git a/packages/flutter_service_worker/lib/src/_io_impl.dart b/packages/flutter_service_worker/lib/src/_io_impl.dart index ea88840d50d5..32f1d251fd9d 100644 --- a/packages/flutter_service_worker/lib/src/_io_impl.dart +++ b/packages/flutter_service_worker/lib/src/_io_impl.dart @@ -10,21 +10,11 @@ import '../flutter_service_worker.dart'; /// platforms. class ServiceWorkerImpl extends ServiceWorkerApi { @override - Future get installPromptReady => Completer().future; + Future get installPromptReady => Completer().future; @override void init() {} @override - Future showInstallPrompt() { - throw UnsupportedError('showInstallPrompt is only supported on the web.'); - } - - @override - Future get newVersionReady => Completer().future; - - @override - void reload() { - throw UnsupportedError('reload is only supported on the web.'); - } + Future get newVersionReady => Completer().future; } diff --git a/packages/flutter_service_worker/lib/src/_web_impl.dart b/packages/flutter_service_worker/lib/src/_web_impl.dart index 3198b2a52a6a..db983ea1fd31 100644 --- a/packages/flutter_service_worker/lib/src/_web_impl.dart +++ b/packages/flutter_service_worker/lib/src/_web_impl.dart @@ -22,13 +22,13 @@ class ServiceWorkerImpl extends ServiceWorkerApi { } _installPrompt = JsObject.fromBrowserObject(event) ..callMethod('preventDefault'); - _installPromptReady.complete(); + _installPromptReady.complete(_WebInstallResponse(_installPrompt)); })); window.navigator.serviceWorker.ready .then((ServiceWorkerRegistration registration) { if (registration.waiting != null) { - if (!_installPromptReady.isCompleted) { - _newVersionReady.complete(); + if (!_newVersionReady.isCompleted) { + _newVersionReady.complete(_WebUpdateResponse()); } } if (registration.installing != null) { @@ -43,19 +43,30 @@ class ServiceWorkerImpl extends ServiceWorkerApi { void _handleInstall(ServiceWorker serviceWorker) { serviceWorker.addEventListener('statechange', (_) { if (serviceWorker.state == 'installed') { - if (!_installPromptReady.isCompleted) { - _installPromptReady.complete(); + if (!_newVersionReady.isCompleted) { + _newVersionReady.complete(); } } }); } - final Completer _installPromptReady = Completer(); - final Completer _newVersionReady = Completer(); + final Completer<_WebInstallResponse> _installPromptReady = + Completer<_WebInstallResponse>(); + final Completer<_WebUpdateResponse> _newVersionReady = + Completer<_WebUpdateResponse>(); JsObject _installPrompt; @override - Future get installPromptReady => _installPromptReady.future; + Future get installPromptReady => _installPromptReady.future; + + @override + Future get newVersionReady => _newVersionReady.future; +} + +class _WebInstallResponse extends InstallResponse { + _WebInstallResponse(this._installPrompt); + + final JsObject _installPrompt; @override Future showInstallPrompt() async { @@ -73,12 +84,12 @@ class ServiceWorkerImpl extends ServiceWorkerApi { await promiseToFuture(getProperty(_installPrompt, 'userChoice')); return result == 'accepted'; } +} +class _WebUpdateResponse extends UpdateResponse { @override - Future get newVersionReady => _newVersionReady.future; - - @override - Future reload() { + void reload() { + // TODO(jonahwilliams): on Safari force refresh. window.location.reload(); } } diff --git a/packages/flutter_service_worker/test/web_test.dart b/packages/flutter_service_worker/test/web_test.dart deleted file mode 100644 index 91c079469475..000000000000 --- a/packages/flutter_service_worker/test/web_test.dart +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2020 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -@TestOn('chrome') -import 'package:test/test.dart'; -import 'package:flutter_service_worker/src/_web_impl.dart'; - -void main() { - test( - 'throws an Error if showInstallPrompt is called before installPromptReady resolves', - () { - final ServiceWorkerImpl api = ServiceWorkerImpl()..init(); - - // Could be either assertion or StateError depending on mode. - expect(() => api.showInstallPrompt(), throwsA(isA())); - }); -}