diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index bf3ba78..63d6c46 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -455,6 +455,35 @@ jobs: flutter pub get flutter build web --debug --target=./lib/main.dart + web_plugin_unit_test: + needs: setup_matrix + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + matrix: + flutter_version: ${{ fromJson(needs.setup_matrix.outputs.flutter_versions_json) }} + name: Web Plugin Unit Test (${{ matrix.flutter_version }}) + steps: + - uses: actions/checkout@v6 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ (contains(matrix.flutter_version, '.') || contains(matrix.flutter_version, 'x')) && matrix.flutter_version || '' }} + channel: ${{ (!contains(matrix.flutter_version, '.') && !contains(matrix.flutter_version, 'x')) && matrix.flutter_version || '' }} + cache: true + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + + - name: Install dependencies + run: flutter pub get + working-directory: wakelock_plus + + - name: Run Web Plugin Tests + run: | + flutter test \ + --platform chrome \ + --dart-define=WEB_PLUGIN_TESTS=true \ + test/wakelock_plus_web_plugin_test.dart + working-directory: wakelock_plus + web_integration_test: needs: setup_matrix runs-on: ubuntu-latest diff --git a/wakelock_plus/example/.gitignore b/wakelock_plus/example/.gitignore index d0a10c6..143f480 100644 --- a/wakelock_plus/example/.gitignore +++ b/wakelock_plus/example/.gitignore @@ -31,12 +31,9 @@ .pub/ /build/ -# Web related -lib/generated_plugin_registrant.dart - # Symbolication related app.*.symbols # Obfuscation related app.*.map.json -!/ios/Podfile \ No newline at end of file +!/ios/Podfile diff --git a/wakelock_plus/assets/no_sleep.js b/wakelock_plus/lib/assets/no_sleep.js similarity index 77% rename from wakelock_plus/assets/no_sleep.js rename to wakelock_plus/lib/assets/no_sleep.js index ccfab74..3ae1af0 100644 --- a/wakelock_plus/assets/no_sleep.js +++ b/wakelock_plus/lib/assets/no_sleep.js @@ -1,3 +1,32 @@ +/*! Based On NoSleep.js v0.12.0 - git.io/vfn01 - Rich Tibbett - MIT license */ + +class PromiseCompleter { + _promise; + _resolve; + _reject; + constructor() { + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + } + + isCompleted = false; + + get future() { + return this._promise; + } + + complete(value) { + this.isCompleted = true; + this._resolve(value); + } + + completeError(error) { + this._reject(error); + } +} + var webm = 'data:video/webm;base64,GkXfo0AgQoaBAUL3gQFC8oEEQvOBCEKCQAR3ZWJtQoeBAkKFgQIYU4BnQI0VSalmQCgq17FAAw9CQE2AQAZ3aGFtbXlXQUAGd2hhbW15RIlACECPQAAAAAAAFlSua0AxrkAu14EBY8WBAZyBACK1nEADdW5khkAFVl9WUDglhohAA1ZQOIOBAeBABrCBCLqBCB9DtnVAIueBAKNAHIEAAIAwAQCdASoIAAgAAUAmJaQAA3AA/vz0AAA=' var mp4 = @@ -26,48 +55,28 @@ function _classCallCheck(instance, Constructor) { } } -// Detect iOS browsers < version 10 -var oldIOS = - typeof navigator !== 'undefined' && - parseFloat( - ( - '' + - (/CPU.*OS ([0-9_]{3,4})[0-9_]{0,1}|(CPU like).*AppleWebKit.*Mobile/i.exec( - navigator.userAgent - ) || [0, ''])[1] - ) - .replace('undefined', '3_2') - .replace('_', '.') - .replace('_', '') - ) < 10 && - !window.MSStream - // Detect native Wake Lock API support var nativeWakeLock = 'wakeLock' in navigator var NoSleep = (function () { - var _releasedNative = true - var _nativeRequestInProgress = false + var _nativeEnabledCompleter; + var _playVideoCompleter; function NoSleep() { var _this = this _classCallCheck(this, NoSleep) + this.nativeEnabled = false if (nativeWakeLock) { this._wakeLock = null var handleVisibilityChange = function handleVisibilityChange() { - if ( - _this._wakeLock !== null && - document.visibilityState === 'visible' - ) { + if (_this._wakeLock !== null && document.visibilityState === 'visible') { _this.enable() } } document.addEventListener('visibilitychange', handleVisibilityChange) document.addEventListener('fullscreenchange', handleVisibilityChange) - } else if (oldIOS) { - this.noSleepTimer = null } else { // Set up no sleep video element this.noSleepVideo = document.createElement('video') @@ -106,90 +115,92 @@ var NoSleep = (function () { }, { key: 'enable', - value: function enable() { + value: async function enable() { var _this2 = this if (nativeWakeLock) { - _nativeRequestInProgress = true + // Disable any previously held wakelocks. + await this.disable() + if (_nativeEnabledCompleter == null) { + _nativeEnabledCompleter = new PromiseCompleter() + } navigator.wakeLock .request('screen') .then(function (wakeLock) { - _releasedNative = false - _nativeRequestInProgress = false - _this2._wakeLock = wakeLock + _this2.nativeEnabled = true + + // We now have a wakelock. Notify all of the existing callers. _this2._wakeLock.addEventListener('release', function () { - _releasedNative = true + _this2.nativeEnabled = false _this2._wakeLock = null }) + + _nativeEnabledCompleter.complete() + _nativeEnabledCompleter = null }) .catch(function (err) { - _nativeRequestInProgress = false - console.error(err.name + ', ' + err.message) + _this2.nativeEnabled = false + var errorMessage = err.name + ', ' + err.message + _nativeEnabledCompleter.completeError(errorMessage) + _nativeEnabledCompleter = null }) - } else if (oldIOS) { - this.disable() - console.warn( - '\n NoSleep enabled for older iOS devices. This can interrupt\n active or long-running network requests from completing successfully.\n See https://github.com/richtr/NoSleep.js/issues/15 for more details.\n ' - ) - this.noSleepTimer = window.setInterval(function () { - if (!document.hidden) { - window.location.href = window.location.href.split('#')[0] - window.setTimeout(window.stop, 0) - } - }, 15000) + // We then wait for screen to be made available. + return _nativeEnabledCompleter.future } else { - this.noSleepVideo.play() + if (_playVideoCompleter == null) { + _playVideoCompleter = new PromiseCompleter() + } + var playPromise = this.noSleepVideo.play() + playPromise.then(function (res) { + _playVideoCompleter.complete() + _playVideoCompleter = null + }).catch(function (err) { + var errorMessage = err.name + ', ' + err.message + _playVideoCompleter.completeError(errorMessage) + _playVideoCompleter = null + }); + return _playVideoCompleter.future } }, }, { key: 'disable', - value: function disable() { + value: async function disable() { if (nativeWakeLock) { + // If we're still trying to enable the wakelock, wait for it to be enabled + if (_nativeEnabledCompleter != null) { + await _nativeEnabledCompleter.future + } if (this._wakeLock != null) { - _releasedNative = true + this.nativeEnabled = false this._wakeLock.release() } this._wakeLock = null - } else if (oldIOS) { - if (this.noSleepTimer) { - console.warn( - '\n NoSleep now disabled for older iOS devices.\n ' - ) - window.clearInterval(this.noSleepTimer) - this.noSleepTimer = null - } } else { + if (_playVideoCompleter != null) { + await _playVideoCompleter.future + } this.noSleepVideo.pause() } + return Promise.resolve(); }, }, { - key: 'enabled', - value: async function enabled() { + key: 'isEnabled', + value: async function isEnabled() { if (nativeWakeLock) { - if (_nativeRequestInProgress == true) { - // Wait until the request is done. - while (true) { - // Wait for 42 milliseconds. - await new Promise((resolve, reject) => setTimeout(resolve, 42)) - if (_nativeRequestInProgress == false) { - break - } - } - } - - // todo: use WakeLockSentinel.released when that is available (https://developer.mozilla.org/en-US/docs/Web/API/WakeLockSentinel/released) - if (_releasedNative != false) { - return false + // If we're still trying to enable the wakelock, wait for it to be enabled + if (_nativeEnabledCompleter != null) { + await _nativeEnabledCompleter.future } - return true - } else if (oldIOS) { - return this.noSleepTimer != null + return this.nativeEnabled } else { + if (_playVideoCompleter != null) { + await _playVideoCompleter.future + } if (this.noSleepVideo == undefined) { return false } @@ -208,17 +219,22 @@ var noSleep = new NoSleep() var Wakelock = { enabled: async function () { try { - return noSleep.enabled() + return noSleep.isEnabled() } catch (e) { return false } }, toggle: async function (enable) { - if (enable) { - noSleep.enable() - } else { - noSleep.disable() + try { + if (enable) { + await noSleep.enable() + } else { + await noSleep.disable() + } + } catch (e) { + return Promise.reject(e); } + return Promise.resolve() }, } diff --git a/wakelock_plus/lib/src/wakelock_plus_web_plugin.dart b/wakelock_plus/lib/src/wakelock_plus_web_plugin.dart index a21ed32..efea394 100644 --- a/wakelock_plus/lib/src/wakelock_plus_web_plugin.dart +++ b/wakelock_plus/lib/src/wakelock_plus_web_plugin.dart @@ -1,11 +1,10 @@ import 'dart:async'; -import 'dart:js_interop'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; -import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; import 'package:wakelock_plus/src/web_impl/import_js_library.dart'; import 'package:wakelock_plus/src/web_impl/js_wakelock.dart' as wakelock_plus_web; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; /// The web implementation of the [WakelockPlatformInterface]. /// @@ -14,45 +13,37 @@ class WakelockPlusWebPlugin extends WakelockPlusPlatformInterface { /// Registers [WakelockPlusWebPlugin] as the default instance of the /// [WakelockPlatformInterface]. static void registerWith(Registrar registrar) { - // Import a version of `NoSleep.js` that was adjusted for the wakelock - // plugin. - _jsLoaded = importJsLibrary( + WakelockPlusPlatformInterface.instance = WakelockPlusWebPlugin(); + } + + // The future that signals when the JS is loaded. + // This needs to be `await`ed before accessing any methods of the + // JS-interop layer. + Future? _jsLoaded; + + // + // Lazily imports the JS library once, then awaits to ensure that + // it's loaded into the DOM. + // + Future _ensureJsLoaded() async { + _jsLoaded ??= importJsLibrary( url: 'assets/no_sleep.js', flutterPluginName: 'wakelock_plus', ); - - WakelockPlusPlatformInterface.instance = WakelockPlusWebPlugin(); + return _jsLoaded; } - // The future that resolves when the JS library is loaded. - static late Future _jsLoaded; - @override Future toggle({required bool enable}) async { // Make sure the JS library is loaded before calling it. - await _jsLoaded; - - wakelock_plus_web.toggle(enable); + await _ensureJsLoaded(); + await wakelock_plus_web.toggle(enable); } @override Future get enabled async { // Make sure the JS library is loaded before calling it. - await _jsLoaded; - - final completer = Completer(); - - wakelock_plus_web.enabled().toDart.then( - // onResolve - (value) { - completer.complete(value.toDart); - }, - // onReject - onError: (error) { - completer.completeError(error); - }, - ); - - return completer.future; + await _ensureJsLoaded(); + return wakelock_plus_web.enabled(); } } diff --git a/wakelock_plus/lib/src/web_impl/import_js_library.dart b/wakelock_plus/lib/src/web_impl/import_js_library.dart index 461b47e..43ac876 100644 --- a/wakelock_plus/lib/src/web_impl/import_js_library.dart +++ b/wakelock_plus/lib/src/web_impl/import_js_library.dart @@ -1,6 +1,7 @@ -import 'dart:js_interop'; +import 'dart:async'; +import 'dart:ui_web' as ui_web; -import 'package:web/web.dart'; +import 'package:web/web.dart' as web; /// This is an implementation of the `import_js_library` plugin that is used /// until that plugin is migrated to null safety. @@ -20,59 +21,97 @@ Future importJsLibrary({ } String _libraryUrl(String url, String pluginName) { + // Added suggested changes as per + // https://github.com/fluttercommunity/wakelock_plus/issues/19#issuecomment-2301963609 if (url.startsWith('./')) { url = url.replaceFirst('./', ''); - return './assets/packages/$pluginName/$url'; } + if (url.startsWith('assets/')) { - return './assets/packages/$pluginName/$url'; - } else { - return url; + if (const bool.fromEnvironment('WEB_PLUGIN_TESTS', defaultValue: false)) { + // Flutter tests running on Chrome just need to use the library path + // without pre-pending "assets/". + // + // In other words, don't use the asset manager since it's not currently + // supported for Chrome-based Flutter tests. + // + // See https://github.com/flutter/flutter/issues/159879 for more details. + // TODO: Remove the workaround once test asset support is added + // for tests running in Chrome. + return 'packages/$pluginName/$url'; + } + return ui_web.assetManager.getAssetUrl('packages/$pluginName/$url'); } + + return url; } -HTMLScriptElement _createScriptTag(String library) { - final script = document.createElement('script') as HTMLScriptElement +Future? _importRunning; +Map _loadedLibraries = {}; +int _nextLibraryId = 0; + +web.HTMLScriptElement _createScriptTag(String library) { + final scriptId = 'imported-js-library-${_nextLibraryId++}'; + final script = web.document.createElement('script') as web.HTMLScriptElement ..type = 'text/javascript' ..charset = 'utf-8' ..async = true - ..src = library; + ..src = library + ..id = scriptId; return script; } /// Injects a bunch of libraries in the `` and returns a /// Future that resolves when all load. -Future _importJSLibraries(List libraries) { +Future _importJSLibraries(List libraries) async { + // we add the library to _loadedLibraries asynchronously, so we need locking. + // Dart uses voluntary preemption, so everything between two `await`s can be + // considered locked + while (_importRunning != null) { + await _importRunning; + } + final importLockCompleter = Completer(); + _importRunning = importLockCompleter.future; final loading = >[]; - final head = document.head; + final head = web.document.head; for (final library in libraries) { if (!_isImported(library)) { final scriptTag = _createScriptTag(library); head!.appendChild(scriptTag); - loading.add(scriptTag.onLoad.first); + final completer = Completer(); + loading.add(completer.future); + + scriptTag.onLoad.first.then((_) { + _loadedLibraries[library] = scriptTag.id; + completer.complete(); + }); + scriptTag.onError.first.then( + (event) => + completer.completeError(Exception('Error loading: $library')), + ); } } - return Future.wait(loading); + try { + await Future.wait(loading, eagerError: true); + } finally { + // first "unlock" future, then complete the completer for anyone already waiting. + // I'm not sure if `.complete()` is yielding execution, so this is the safe order + _importRunning = null; + importLockCompleter.complete(); + } } bool _isImported(String url) { - final head = document.head!; + final head = web.document.head!; return _isLoaded(head, url); } -bool _isLoaded(HTMLHeadElement head, String url) { - if (url.startsWith('./')) { - url = url.replaceFirst('./', ''); - } - for (int i = 0; i < head.children.length; i++) { - final element = head.children.item(i)!; - if (element.instanceOfString('HTMLScriptElement')) { - if ((element as HTMLScriptElement).src.endsWith(url)) { - return true; - } - } +bool _isLoaded(web.HTMLHeadElement head, String url) { + final scriptId = _loadedLibraries[url]; + if (scriptId == null) { + return false; } - return false; + return head.querySelector('#$scriptId') != null; } diff --git a/wakelock_plus/lib/src/web_impl/js_wakelock.dart b/wakelock_plus/lib/src/web_impl/js_wakelock.dart index 38b7822..972c77e 100644 --- a/wakelock_plus/lib/src/web_impl/js_wakelock.dart +++ b/wakelock_plus/lib/src/web_impl/js_wakelock.dart @@ -3,10 +3,18 @@ library; import 'dart:js_interop'; +@JS('toggle') +external JSPromise _toggle(JSBoolean enable); + /// Toggles the JS wakelock. -@JS() -external void toggle(bool enable); +Future toggle(bool enable) { + return _toggle(enable.toJS).toDart.then((_) => null); +} + +@JS('enabled') +external JSPromise _enabled(); /// Returns a JS promise of whether the wakelock is enabled or not. -@JS() -external JSPromise enabled(); +Future enabled() { + return _enabled().toDart.then((enabled) => enabled.toDart); +} diff --git a/wakelock_plus/pubspec.yaml b/wakelock_plus/pubspec.yaml index 5fa9847..73648c3 100644 --- a/wakelock_plus/pubspec.yaml +++ b/wakelock_plus/pubspec.yaml @@ -99,4 +99,4 @@ flutter: # https://flutter.dev/custom-fonts/#from-packages assets: - - assets/no_sleep.js + - packages/wakelock_plus/assets/no_sleep.js diff --git a/wakelock_plus/test/wakelock_plus_web_plugin_test.dart b/wakelock_plus/test/wakelock_plus_web_plugin_test.dart index 11c0529..896d662 100644 --- a/wakelock_plus/test/wakelock_plus_web_plugin_test.dart +++ b/wakelock_plus/test/wakelock_plus_web_plugin_test.dart @@ -6,13 +6,23 @@ import 'package:wakelock_plus/src/wakelock_plus_web_plugin.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; +/// +/// Run these tests with: +/// flutter test \ +/// --platform chrome \ +/// --dart-define=WEB_PLUGIN_TESTS=true \ +/// test/wakelock_plus_web_plugin_test.dart +/// void main() { group('$WakelockPlusWebPlugin', () { setUpAll(() async { - // todo: the web tests do not work as the JS library import does not work. WakelockPlusPlatformInterface.instance = WakelockPlusWebPlugin(); }); + tearDown(() async { + await WakelockPlus.disable(); + }); + test('$WakelockPlusWebPlugin set as default instance', () { expect( WakelockPlusPlatformInterface.instance, @@ -29,7 +39,23 @@ void main() { expect(WakelockPlus.enabled, completion(isTrue)); }); + test('enable more than once', () async { + await WakelockPlus.enable(); + await WakelockPlus.enable(); + await WakelockPlus.enable(); + expect(WakelockPlus.enabled, completion(isTrue)); + }); + test('disable', () async { + await WakelockPlus.enable(); + await WakelockPlus.disable(); + expect(WakelockPlus.enabled, completion(isFalse)); + }); + + test('disable more than once', () async { + await WakelockPlus.enable(); + await WakelockPlus.disable(); + await WakelockPlus.disable(); await WakelockPlus.disable(); expect(WakelockPlus.enabled, completion(isFalse)); });