From 195a23d1fd7681bcac78ab0002e367b4acaeb73c Mon Sep 17 00:00:00 2001 From: Michal Terepeta Date: Wed, 23 Sep 2020 15:42:22 +0200 Subject: [PATCH 1/5] Implement the ability to register custom watcher implementations This allows registering a special-purpose factory that returns its own `Watcher` implementation that will take precedence over the default ones. The main motivation for this is handling of file systems that need custom code to watch for changes. --- lib/src/custom_watcher_factory.dart | 46 +++++++++ lib/src/directory_watcher.dart | 11 ++- lib/src/file_watcher.dart | 8 ++ lib/watcher.dart | 9 +- test/custom_watcher_factory_test.dart | 130 ++++++++++++++++++++++++++ 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 lib/src/custom_watcher_factory.dart create mode 100644 test/custom_watcher_factory_test.dart diff --git a/lib/src/custom_watcher_factory.dart b/lib/src/custom_watcher_factory.dart new file mode 100644 index 0000000..ffac06b --- /dev/null +++ b/lib/src/custom_watcher_factory.dart @@ -0,0 +1,46 @@ +import '../watcher.dart'; + +/// Defines a way to create a custom watcher instead of the default ones. +/// +/// This will be used when a [DirectoryWatcher] or [FileWatcher] would be +/// created and will take precedence over the default ones. +abstract class CustomWatcherFactory { + /// Uniquely identify this watcher. + String get id; + + /// Tries to create a [DirectoryWatcher] for the provided path. + /// + /// Should return `null` if the path is not supported by this factory. + DirectoryWatcher createDirectoryWatcher(String path, {Duration pollingDelay}); + + /// Tries to create a [FileWatcher] for the provided path. + /// + /// Should return `null` if the path is not supported by this factory. + FileWatcher createFileWatcher(String path, {Duration pollingDelay}); +} + +/// Registers a custom watcher. +/// +/// It's only allowed to register a watcher once per [id]. The [supportsPath] +/// will be called to determine if the [createWatcher] should be used instead of +/// the built-in watchers. +/// +/// Note that we will try [CustomWatcherFactory] one by one in the order they +/// were registered. +void registerCustomWatcherFactory(CustomWatcherFactory customFactory) { + if (_customWatcherFactories.containsKey(customFactory.id)) { + throw ArgumentError('A custom watcher with id `${customFactory.id}` ' + 'has already been registered'); + } + _customWatcherFactories[customFactory.id] = customFactory; +} + +/// Unregisters a custom watcher and returns it (returns `null` if it was never +/// registered). +CustomWatcherFactory unregisterCustomWatcherFactory(String id) => + _customWatcherFactories.remove(id); + +Iterable get customWatcherFactories => + _customWatcherFactories.values; + +final _customWatcherFactories = {}; diff --git a/lib/src/directory_watcher.dart b/lib/src/directory_watcher.dart index e0ef3fc..858d020 100644 --- a/lib/src/directory_watcher.dart +++ b/lib/src/directory_watcher.dart @@ -5,10 +5,11 @@ import 'dart:io'; import '../watcher.dart'; +import 'custom_watcher_factory.dart'; import 'directory_watcher/linux.dart'; import 'directory_watcher/mac_os.dart'; -import 'directory_watcher/windows.dart'; import 'directory_watcher/polling.dart'; +import 'directory_watcher/windows.dart'; /// Watches the contents of a directory and emits [WatchEvent]s when something /// in the directory has changed. @@ -29,6 +30,14 @@ abstract class DirectoryWatcher implements Watcher { /// watchers. factory DirectoryWatcher(String directory, {Duration pollingDelay}) { if (FileSystemEntity.isWatchSupported) { + for (var custom in customWatcherFactories) { + var watcher = custom.createDirectoryWatcher(directory, + pollingDelay: pollingDelay); + if (watcher != null) { + return watcher; + } + } + if (Platform.isLinux) return LinuxDirectoryWatcher(directory); if (Platform.isMacOS) return MacOSDirectoryWatcher(directory); if (Platform.isWindows) return WindowsDirectoryWatcher(directory); diff --git a/lib/src/file_watcher.dart b/lib/src/file_watcher.dart index c4abddd..0b7afc7 100644 --- a/lib/src/file_watcher.dart +++ b/lib/src/file_watcher.dart @@ -5,6 +5,7 @@ import 'dart:io'; import '../watcher.dart'; +import 'custom_watcher_factory.dart'; import 'file_watcher/native.dart'; import 'file_watcher/polling.dart'; @@ -29,6 +30,13 @@ abstract class FileWatcher implements Watcher { /// and higher CPU usage. Defaults to one second. Ignored for non-polling /// watchers. factory FileWatcher(String file, {Duration pollingDelay}) { + for (var custom in customWatcherFactories) { + var watcher = custom.createFileWatcher(file, pollingDelay: pollingDelay); + if (watcher != null) { + return watcher; + } + } + // [File.watch] doesn't work on Windows, but // [FileSystemEntity.isWatchSupported] is still true because directory // watching does work. diff --git a/lib/watcher.dart b/lib/watcher.dart index 107ac8f..f5c7d3e 100644 --- a/lib/watcher.dart +++ b/lib/watcher.dart @@ -5,15 +5,20 @@ import 'dart:async'; import 'dart:io'; -import 'src/watch_event.dart'; import 'src/directory_watcher.dart'; import 'src/file_watcher.dart'; +import 'src/watch_event.dart'; -export 'src/watch_event.dart'; +export 'src/custom_watcher_factory.dart' + show + CustomWatcherFactory, + registerCustomWatcherFactory, + unregisterCustomWatcherFactory; export 'src/directory_watcher.dart'; export 'src/directory_watcher/polling.dart'; export 'src/file_watcher.dart'; export 'src/file_watcher/polling.dart'; +export 'src/watch_event.dart'; abstract class Watcher { /// The path to the file or directory whose contents are being monitored. diff --git a/test/custom_watcher_factory_test.dart b/test/custom_watcher_factory_test.dart new file mode 100644 index 0000000..488b607 --- /dev/null +++ b/test/custom_watcher_factory_test.dart @@ -0,0 +1,130 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:watcher/watcher.dart'; + +void main() { + _MemFs memFs; + + setUp(() { + memFs = _MemFs(); + registerCustomWatcherFactory(_MemFsWatcherFactory(memFs)); + }); + + tearDown(() async { + unregisterCustomWatcherFactory('MemFs'); + }); + + test('notifes for files', () async { + var watcher = FileWatcher('file.txt'); + + var completer = Completer(); + watcher.events.listen((event) => completer.complete(event)); + await watcher.ready; + memFs.add('file.txt'); + var event = await completer.future; + + expect(event.type, ChangeType.ADD); + expect(event.path, 'file.txt'); + }); + + test('notifes for directories', () async { + var watcher = DirectoryWatcher('dir'); + + var completer = Completer(); + watcher.events.listen((event) => completer.complete(event)); + await watcher.ready; + memFs.add('dir'); + var event = await completer.future; + + expect(event.type, ChangeType.ADD); + expect(event.path, 'dir'); + }); + + test('unregister works', () async { + var memFactory = _MemFsWatcherFactory(memFs); + unregisterCustomWatcherFactory(memFactory.id); + + var completer = Completer(); + var watcher = FileWatcher('file.txt'); + watcher.events.listen((e) {}, onError: (e) => completer.complete(e)); + await watcher.ready; + memFs.add('file.txt'); + var result = await completer.future; + + expect(result, isA()); + }); + + test('registering twice throws', () async { + expect(() => registerCustomWatcherFactory(_MemFsWatcherFactory(memFs)), + throwsA(isA())); + }); +} + +class _MemFs { + final _streams = >>{}; + + StreamController watchStream(String path) { + var controller = StreamController(); + _streams.putIfAbsent(path, () => {}).add(controller); + return controller; + } + + void add(String path) { + var controllers = _streams[path]; + if (controllers != null) { + for (var controller in controllers) { + controller.add(WatchEvent(ChangeType.ADD, path)); + } + } + } + + void remove(String path) { + var controllers = _streams[path]; + if (controllers != null) { + for (var controller in controllers) { + controller.add(WatchEvent(ChangeType.REMOVE, path)); + } + } + } +} + +class _MemFsWatcher implements FileWatcher, DirectoryWatcher, Watcher { + final String _path; + final StreamController _controller; + + _MemFsWatcher(this._path, this._controller); + + @override + String get path => _path; + + @override + String get directory => throw UnsupportedError('directory is not supported'); + + @override + Stream get events => _controller.stream; + + @override + bool get isReady => true; + + @override + Future get ready async {} +} + +class _MemFsWatcherFactory implements CustomWatcherFactory { + final _MemFs _memFs; + _MemFsWatcherFactory(this._memFs); + + @override + String get id => 'MemFs'; + + @override + DirectoryWatcher createDirectoryWatcher(String path, + {Duration pollingDelay}) => + _MemFsWatcher(path, _memFs.watchStream(path)); + + @override + FileWatcher createFileWatcher(String path, {Duration pollingDelay}) => + _MemFsWatcher(path, _memFs.watchStream(path)); +} From 9eb219c897e63ff510c7272c5d6613e774e70953 Mon Sep 17 00:00:00 2001 From: Michal Terepeta Date: Thu, 24 Sep 2020 12:33:53 +0200 Subject: [PATCH 2/5] Fix the test that was timing out --- test/custom_watcher_factory_test.dart | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/test/custom_watcher_factory_test.dart b/test/custom_watcher_factory_test.dart index 488b607..784769c 100644 --- a/test/custom_watcher_factory_test.dart +++ b/test/custom_watcher_factory_test.dart @@ -46,14 +46,16 @@ void main() { var memFactory = _MemFsWatcherFactory(memFs); unregisterCustomWatcherFactory(memFactory.id); - var completer = Completer(); + var events = []; var watcher = FileWatcher('file.txt'); - watcher.events.listen((e) {}, onError: (e) => completer.complete(e)); + watcher.events.listen((e) => events.add(e)); await watcher.ready; - memFs.add('file.txt'); - var result = await completer.future; + memFs.add('a.txt'); + memFs.add('b.txt'); + memFs.add('c.txt'); + await Future.delayed(Duration(seconds: 1)); - expect(result, isA()); + expect(events, isEmpty); }); test('registering twice throws', () async { From 03346d579a080fbc8a0b367637ef600d71c89e00 Mon Sep 17 00:00:00 2001 From: Michal Terepeta Date: Thu, 24 Sep 2020 14:54:44 +0200 Subject: [PATCH 3/5] Throw when more than one custom factory is applicable --- lib/src/custom_watcher_factory.dart | 57 ++++++++++++++++++++++----- lib/src/directory_watcher.dart | 11 ++---- lib/src/file_watcher.dart | 9 ++--- test/custom_watcher_factory_test.dart | 27 ++++++++----- 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/lib/src/custom_watcher_factory.dart b/lib/src/custom_watcher_factory.dart index ffac06b..b7f81fa 100644 --- a/lib/src/custom_watcher_factory.dart +++ b/lib/src/custom_watcher_factory.dart @@ -10,23 +10,20 @@ abstract class CustomWatcherFactory { /// Tries to create a [DirectoryWatcher] for the provided path. /// - /// Should return `null` if the path is not supported by this factory. + /// Returns `null` if the path is not supported by this factory. DirectoryWatcher createDirectoryWatcher(String path, {Duration pollingDelay}); /// Tries to create a [FileWatcher] for the provided path. /// - /// Should return `null` if the path is not supported by this factory. + /// Returns `null` if the path is not supported by this factory. FileWatcher createFileWatcher(String path, {Duration pollingDelay}); } /// Registers a custom watcher. /// -/// It's only allowed to register a watcher once per [id]. The [supportsPath] -/// will be called to determine if the [createWatcher] should be used instead of -/// the built-in watchers. -/// -/// Note that we will try [CustomWatcherFactory] one by one in the order they -/// were registered. +/// It's only allowed to register a watcher factory once per [id] and at most +/// one factory should apply to any given file (creating a [Watcher] will fail +/// otherwise). void registerCustomWatcherFactory(CustomWatcherFactory customFactory) { if (_customWatcherFactories.containsKey(customFactory.id)) { throw ArgumentError('A custom watcher with id `${customFactory.id}` ' @@ -35,8 +32,48 @@ void registerCustomWatcherFactory(CustomWatcherFactory customFactory) { _customWatcherFactories[customFactory.id] = customFactory; } -/// Unregisters a custom watcher and returns it (returns `null` if it was never -/// registered). +/// Tries to create a custom [DirectoryWatcher] and returns it. +/// +/// Returns `null` if no custom watcher was applicable and throws a [StateError] +/// if more than one was. +DirectoryWatcher createCustomDirectoryWatcher(String path, + {Duration pollingDelay}) { + DirectoryWatcher customWatcher; + String customFactoryId; + for (var watcherFactory in customWatcherFactories) { + if (customWatcher != null) { + throw StateError('Two `CustomWatcherFactory`s applicable: ' + '`$customFactoryId` and `${watcherFactory.id}` for `$path`'); + } + customWatcher = + watcherFactory.createDirectoryWatcher(path, pollingDelay: pollingDelay); + customFactoryId = watcherFactory.id; + } + return customWatcher; +} + +/// Tries to create a custom [FileWatcher] and returns it. +/// +/// Returns `null` if no custom watcher was applicable and throws a [StateError] +/// if more than one was. +FileWatcher createCustomFileWatcher(String path, {Duration pollingDelay}) { + FileWatcher customWatcher; + String customFactoryId; + for (var watcherFactory in customWatcherFactories) { + if (customWatcher != null) { + throw StateError('Two `CustomWatcherFactory`s applicable: ' + '`$customFactoryId` and `${watcherFactory.id}` for `$path`'); + } + customWatcher = + watcherFactory.createFileWatcher(path, pollingDelay: pollingDelay); + customFactoryId = watcherFactory.id; + } + return customWatcher; +} + +/// Unregisters a custom watcher and returns it. +/// +/// Returns `null` if the id was never registered. CustomWatcherFactory unregisterCustomWatcherFactory(String id) => _customWatcherFactories.remove(id); diff --git a/lib/src/directory_watcher.dart b/lib/src/directory_watcher.dart index 858d020..3fe5004 100644 --- a/lib/src/directory_watcher.dart +++ b/lib/src/directory_watcher.dart @@ -30,14 +30,9 @@ abstract class DirectoryWatcher implements Watcher { /// watchers. factory DirectoryWatcher(String directory, {Duration pollingDelay}) { if (FileSystemEntity.isWatchSupported) { - for (var custom in customWatcherFactories) { - var watcher = custom.createDirectoryWatcher(directory, - pollingDelay: pollingDelay); - if (watcher != null) { - return watcher; - } - } - + var customWatcher = + createCustomDirectoryWatcher(directory, pollingDelay: pollingDelay); + if (customWatcher != null) return customWatcher; if (Platform.isLinux) return LinuxDirectoryWatcher(directory); if (Platform.isMacOS) return MacOSDirectoryWatcher(directory); if (Platform.isWindows) return WindowsDirectoryWatcher(directory); diff --git a/lib/src/file_watcher.dart b/lib/src/file_watcher.dart index 0b7afc7..c41e5e6 100644 --- a/lib/src/file_watcher.dart +++ b/lib/src/file_watcher.dart @@ -30,12 +30,9 @@ abstract class FileWatcher implements Watcher { /// and higher CPU usage. Defaults to one second. Ignored for non-polling /// watchers. factory FileWatcher(String file, {Duration pollingDelay}) { - for (var custom in customWatcherFactories) { - var watcher = custom.createFileWatcher(file, pollingDelay: pollingDelay); - if (watcher != null) { - return watcher; - } - } + var customWatcher = + createCustomFileWatcher(file, pollingDelay: pollingDelay); + if (customWatcher != null) return customWatcher; // [File.watch] doesn't work on Windows, but // [FileSystemEntity.isWatchSupported] is still true because directory diff --git a/test/custom_watcher_factory_test.dart b/test/custom_watcher_factory_test.dart index 784769c..6d9ed40 100644 --- a/test/custom_watcher_factory_test.dart +++ b/test/custom_watcher_factory_test.dart @@ -1,19 +1,19 @@ import 'dart:async'; -import 'dart:io'; import 'package:test/test.dart'; import 'package:watcher/watcher.dart'; void main() { _MemFs memFs; + final defaultFactoryId = 'MemFs'; setUp(() { memFs = _MemFs(); - registerCustomWatcherFactory(_MemFsWatcherFactory(memFs)); + registerCustomWatcherFactory(_MemFsWatcherFactory(defaultFactoryId, memFs)); }); tearDown(() async { - unregisterCustomWatcherFactory('MemFs'); + unregisterCustomWatcherFactory(defaultFactoryId); }); test('notifes for files', () async { @@ -43,8 +43,7 @@ void main() { }); test('unregister works', () async { - var memFactory = _MemFsWatcherFactory(memFs); - unregisterCustomWatcherFactory(memFactory.id); + unregisterCustomWatcherFactory(defaultFactoryId); var events = []; var watcher = FileWatcher('file.txt'); @@ -59,9 +58,19 @@ void main() { }); test('registering twice throws', () async { - expect(() => registerCustomWatcherFactory(_MemFsWatcherFactory(memFs)), + expect( + () => registerCustomWatcherFactory( + _MemFsWatcherFactory(defaultFactoryId, memFs)), throwsA(isA())); }); + + test('finding two applicable factories throws', () async { + // Note that _MemFsWatcherFactory always returns a watcher, so having two + // will always produce a conflict. + registerCustomWatcherFactory(_MemFsWatcherFactory('Different id', memFs)); + expect(() => FileWatcher('file.txt'), throwsA(isA())); + expect(() => DirectoryWatcher('dir'), throwsA(isA())); + }); } class _MemFs { @@ -115,11 +124,9 @@ class _MemFsWatcher implements FileWatcher, DirectoryWatcher, Watcher { } class _MemFsWatcherFactory implements CustomWatcherFactory { + final String id; final _MemFs _memFs; - _MemFsWatcherFactory(this._memFs); - - @override - String get id => 'MemFs'; + _MemFsWatcherFactory(this.id, this._memFs); @override DirectoryWatcher createDirectoryWatcher(String path, From b331e15bf2eb7565ad634e0f5cc1ca6b67fb1624 Mon Sep 17 00:00:00 2001 From: Michal Terepeta Date: Thu, 24 Sep 2020 16:01:46 +0200 Subject: [PATCH 4/5] Another attempt to make the test work cross platform --- test/custom_watcher_factory_test.dart | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/custom_watcher_factory_test.dart b/test/custom_watcher_factory_test.dart index 6d9ed40..964a49d 100644 --- a/test/custom_watcher_factory_test.dart +++ b/test/custom_watcher_factory_test.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:test/test.dart'; import 'package:watcher/watcher.dart'; +import 'utils.dart'; + void main() { _MemFs memFs; final defaultFactoryId = 'MemFs'; @@ -45,16 +47,18 @@ void main() { test('unregister works', () async { unregisterCustomWatcherFactory(defaultFactoryId); - var events = []; - var watcher = FileWatcher('file.txt'); - watcher.events.listen((e) => events.add(e)); - await watcher.ready; - memFs.add('a.txt'); - memFs.add('b.txt'); - memFs.add('c.txt'); - await Future.delayed(Duration(seconds: 1)); + watcherFactory = (path) => FileWatcher(path); + try { + // This uses standard files, so it wouldn't trigger an event in + // _MemFsWatcher. + writeFile('file.txt'); + await startWatcher(path: 'file.txt'); + deleteFile('file.txt'); + } finally { + watcherFactory = null; + } - expect(events, isEmpty); + await expectRemoveEvent('file.txt'); }); test('registering twice throws', () async { From 4ad1ea5cebce947e4dbe82c008506791b8d26324 Mon Sep 17 00:00:00 2001 From: Michal Terepeta Date: Fri, 25 Sep 2020 08:24:35 +0200 Subject: [PATCH 5/5] Minor fixes for earlier Dart versions and lint --- test/custom_watcher_factory_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/custom_watcher_factory_test.dart b/test/custom_watcher_factory_test.dart index 964a49d..6c9ffd8 100644 --- a/test/custom_watcher_factory_test.dart +++ b/test/custom_watcher_factory_test.dart @@ -82,7 +82,9 @@ class _MemFs { StreamController watchStream(String path) { var controller = StreamController(); - _streams.putIfAbsent(path, () => {}).add(controller); + _streams + .putIfAbsent(path, () => >{}) + .add(controller); return controller; } @@ -128,6 +130,7 @@ class _MemFsWatcher implements FileWatcher, DirectoryWatcher, Watcher { } class _MemFsWatcherFactory implements CustomWatcherFactory { + @override final String id; final _MemFs _memFs; _MemFsWatcherFactory(this.id, this._memFs);