diff --git a/CHANGELOG.md b/CHANGELOG.md index 3994e77..f43a8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 0.3.0 (unreleased) + +### New Features + +* Added `HttpCacheManager.init(startServer: false)`, which starts the manager without its local cache server, for apps that read streams directly through `createStream` and `HttpCacheStream.request` instead of over HTTP. No socket is bound, so it works where binding is refused, such as a macOS App Sandbox without the `com.apple.security.network.server` entitlement. Downloading and caching work as usual. The default is unchanged (`startServer: true`). +* Added `HttpCacheManager.hasServer` and `HttpCacheStream.hasCacheUrl`. +* Added `CacheServerUnavailableException` (a `StateError`), thrown by `HttpCacheManager.getCacheUrl` and `HttpCacheStream.cacheUrl` when the manager has no local cache server. Without a server, `ensureActive()` does nothing and `port` is ignored. + +### Fixes + +* Fixed `HttpCacheManager.init` returning the previous, disposed manager after `dispose()` when the init completed without an asynchronous step (no server, with `cacheDir` or `config` given). + ## 0.2.1 ### New Features diff --git a/README.md b/README.md index 66c5491..ab7a3a0 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,22 @@ Reference the file in `android/app/src/main/res/AndroidManifest.xml` by adding t See the example project for a full example. +### Without the local server + +If your app reads streams directly (through `createStream` and `HttpCacheStream.request`) rather than handing a URL to a player, you can skip the local server entirely. No socket is bound, so no cleartext or network-server configuration is needed, and it works in sandboxes that refuse to bind one (e.g. a macOS App Sandbox without the `com.apple.security.network.server` entitlement): + +```dart +await HttpCacheManager.init(startServer: false); + +final stream = HttpCacheManager.instance.createStream(sourceUrl); +final response = await stream.request(start: 0); // downloads and caches as it streams +await for (final chunk in response.stream) { + // ... +} +``` + +Without a server there is no cache URL: `getCacheUrl` and `HttpCacheStream.cacheUrl` throw a `CacheServerUnavailableException` (check `HttpCacheManager.hasServer` or `HttpCacheStream.hasCacheUrl`), and `ensureActive()` does nothing. + # Core Components diff --git a/lib/src/cache_manager/http_cache_manager.dart b/lib/src/cache_manager/http_cache_manager.dart index ece72f9..e4a23d0 100644 --- a/lib/src/cache_manager/http_cache_manager.dart +++ b/lib/src/cache_manager/http_cache_manager.dart @@ -13,7 +13,9 @@ import '../etc/helpers.dart'; /// /// Use [init] to initialize the manager before creating streams. class HttpCacheManager { - final LocalCacheServer _server; + /// Null when [init] was called with `startServer: false`: streams are then read + /// through [HttpCacheStream.request] only, and there is no cache URL to play. + final LocalCacheServer? _server; /// The global configuration used for all streams managed by this manager. final GlobalCacheConfig config; @@ -21,17 +23,28 @@ class HttpCacheManager { final Map _streams = {}; final Map _customCacheFiles = {}; HttpCacheManager._(this._server, this.config) { - _server.start(createStream); + _server?.start(createStream); } + /// Whether the local cache server was started, i.e. whether [getCacheUrl] and [HttpCacheStream.cacheUrl] are available. + /// + /// False when [init] was called with `startServer: false`. + bool get hasServer => _server != null; + /// Gets the cache URL for the given source URL. /// If a custom cache file is provided, it will be saved and used for the cache stream. + /// + /// Throws a [CacheServerUnavailableException] if the manager has no local cache server (see [hasServer]). Uri getCacheUrl(final Uri sourceUrl, {final File? file}) { _checkDisposed(); + final server = _server; + if (server == null) { + throw CacheServerUnavailableException(sourceUrl); + } if (file != null) { _customCacheFiles[sourceUrl.requestKey] = file; } - return _server.encodeSourceUrl(sourceUrl); + return server.encodeSourceUrl(sourceUrl); } /// Ensures the local cache server is accepting connections, restarting it on the same port if it is not. @@ -39,9 +52,11 @@ class HttpCacheManager { /// On iOS, the server's socket can be reclaimed while the app is suspended in the background. The server is checked periodically /// and restarted automatically, but a player may connect before the next check. Call this before retrying a request to a cache URL /// that failed to connect. + /// + /// Does nothing if the manager has no local cache server (see [hasServer]): no server is started or bound. Future ensureActive() { _checkDisposed(); - return _server.ensureActive(); + return _server?.ensureActive() ?? Future.value(); } /// Create a [HttpCacheStream] instance for the given URL. If an instance already exists, the existing instance will be returned. @@ -72,7 +87,7 @@ class HttpCacheManager { final cacheStream = HttpCacheStream( sourceUrl: sourceUrl, - cacheUrl: _server.encodeSourceUrl(sourceUrl), + cacheUrl: _server?.encodeSourceUrl(sourceUrl), files: cacheFiles, config: config ?? createStreamConfig(), ); @@ -178,13 +193,13 @@ class HttpCacheManager { /// The input [url] can either be [sourceUrl] or [cacheUrl]. HttpCacheStream? getExistingStream(Uri url) { _checkDisposed(); - url = _server.decodeSourceUrl(url) ?? url; + url = _server?.decodeSourceUrl(url) ?? url; return _streams[url.requestKey]; } CacheFiles _resolveCacheFiles(Uri sourceUrl, [File? cacheFile]) { if (cacheFile == null) { - sourceUrl = _server.decodeSourceUrl(sourceUrl) ?? sourceUrl; + sourceUrl = _server?.decodeSourceUrl(sourceUrl) ?? sourceUrl; cacheFile = _customCacheFiles[sourceUrl.requestKey] ?? config.cacheFileResolver(config.cacheDirectory, sourceUrl); } @@ -202,7 +217,7 @@ class HttpCacheManager { _onStreamCreated = null; try { - await _server.close(force: true); + await _server?.close(force: true); } finally { _customCacheFiles.clear(); for (final stream in _streams.values.toList()) { @@ -240,31 +255,47 @@ class HttpCacheManager { /// [customHttpClient] is the custom http client to use. If null, a default http client will be used. /// You can also provide [GlobalCacheConfig] for the initial configuration. /// Use `port` to specify the port for the local server. If not provided, a random available port will be used. + /// + /// Set [startServer] to false to skip the local cache server, for consumers that read streams directly through + /// [createStream] and [HttpCacheStream.request] rather than over HTTP. No socket is bound, so this works where binding + /// is refused (e.g. a macOS App Sandbox without the `com.apple.security.network.server` entitlement). Downloading + /// and caching work as usual, but [getCacheUrl] and [HttpCacheStream.cacheUrl] throw a [CacheServerUnavailableException], + /// [ensureActive] does nothing, and [port] is ignored. See [hasServer]. + /// + /// If the manager is already initialized, the existing instance is returned and these arguments are ignored. static Future init({ final Directory? cacheDir, final http.Client? customHttpClient, final GlobalCacheConfig? config, final int? port, + final bool startServer = true, }) { assert(config == null || (cacheDir == null && customHttpClient == null), 'Cannot set cacheDir or httpClient when config is provided. Set them in the config instead.'); if (_instance != null) { return Future.value(instance); } - return _initFuture ??= () async { - try { - final cacheConfig = config ?? - GlobalCacheConfig( - cacheDirectory: - cacheDir ?? await GlobalCacheConfig.defaultCacheDirectory(), - customHttpClient: customHttpClient, - ); - final httpCacheServer = await LocalCacheServer.init(port: port); - return _instance = HttpCacheManager._(httpCacheServer, cacheConfig); - } finally { - _initFuture = null; - } + final pending = _initFuture; + if (pending != null) return pending; + final future = _initFuture = () async { + final cacheConfig = config ?? + GlobalCacheConfig( + cacheDirectory: + cacheDir ?? await GlobalCacheConfig.defaultCacheDirectory(), + customHttpClient: customHttpClient, + ); + final httpCacheServer = + startServer ? await LocalCacheServer.init(port: port) : null; + return _instance = HttpCacheManager._(httpCacheServer, cacheConfig); }(); + // Cleared once the future is stored, not from inside the closure: without a server + // and with cacheDir or config given, the closure has no await, so it would finish + // (and clear _initFuture) before the assignment above, leaving a stale future that + // hands out the old manager after it is disposed. + future.whenComplete(() { + if (identical(_initFuture, future)) _initFuture = null; + }).ignore(); + return future; } /// The singleton instance of [HttpCacheManager]. diff --git a/lib/src/cache_stream/http_cache_stream.dart b/lib/src/cache_stream/http_cache_stream.dart index 31f1c1a..66fb263 100644 --- a/lib/src/cache_stream/http_cache_stream.dart +++ b/lib/src/cache_stream/http_cache_stream.dart @@ -34,7 +34,16 @@ class HttpCacheStream { final Uri sourceUrl; /// The Url of the cached stream served by the local cache server (e.g., http://localhost:8080/http/example.com/file.mp3) - final Uri cacheUrl; + /// + /// Throws a [CacheServerUnavailableException] when the stream has no cache URL, which is the case when + /// `HttpCacheManager.init` was called with `startServer: false`. Check [hasCacheUrl] first, or read the content + /// through [request] instead. + Uri get cacheUrl => + _cacheUrl ?? (throw CacheServerUnavailableException(sourceUrl)); + final Uri? _cacheUrl; + + /// Whether this stream is served by the local cache server, i.e. whether [cacheUrl] is available. + bool get hasCacheUrl => _cacheUrl != null; /// The complete, partial, and metadata files used for the cache. final CacheFiles files; @@ -61,10 +70,10 @@ class HttpCacheStream { HttpCacheStream({ required this.sourceUrl, - required this.cacheUrl, + final Uri? cacheUrl, required this.files, required this.config, - }) { + }) : _cacheUrl = cacheUrl { _initFuture.run(() async { try { _cachedResponseHeaders = @@ -620,5 +629,5 @@ class HttpCacheStream { @override String toString() => - 'HttpCacheStream{sourceUrl: $sourceUrl, cacheUrl: $cacheUrl, cacheFile: $cacheFile}'; + 'HttpCacheStream{sourceUrl: $sourceUrl, cacheUrl: $_cacheUrl, cacheFile: $cacheFile}'; } diff --git a/lib/src/models/exceptions/state_errors.dart b/lib/src/models/exceptions/state_errors.dart index f6ac2b7..9dccf54 100644 --- a/lib/src/models/exceptions/state_errors.dart +++ b/lib/src/models/exceptions/state_errors.dart @@ -8,3 +8,16 @@ class CacheManagerDisposedException extends StateError { CacheManagerDisposedException() : super('Attempted to use a disposed HttpCacheManager'); } + +/// Thrown when a cache URL is requested from an `HttpCacheManager` that was +/// initialized with `startServer: false`. Without the local cache server there +/// is no URL to serve the cache from: read the content through +/// `HttpCacheStream.request` (or `download`) instead. +class CacheServerUnavailableException extends StateError { + final Uri sourceUrl; + CacheServerUnavailableException(this.sourceUrl) + : super( + 'HttpCacheManager was initialized without a local cache server (startServer: false), ' + 'so there is no cache URL for $sourceUrl. Read it through HttpCacheStream.request instead.', + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index ceb4e97..44a713c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: http_cache_stream description: "Simultaneously download, cache, and stream remote content. Perfect for media players and any plugin that streams web content." -version: 0.2.1 +version: 0.3.0 homepage: https://github.com/Colton127/http_cache_stream repository: https://github.com/Colton127/http_cache_stream topics: diff --git a/test/e2e/serverless_test.dart b/test/e2e/serverless_test.dart new file mode 100644 index 0000000..5281d68 --- /dev/null +++ b/test/e2e/serverless_test.dart @@ -0,0 +1,190 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http_cache_stream/http_cache_stream.dart'; + +import '../support/payload.dart'; +import '../support/test_origin.dart'; + +/// `HttpCacheManager.init(startServer: false)` binds no socket (a sandbox +/// without the incoming-network entitlement refuses one): streams still +/// download and cache, read through `createStream` and `request`, and there is +/// no cache URL. +void main() { + late Directory cacheDir; + late TestOrigin origin; + final payload = Payload.generate(256 * 1024); + + setUp(() async { + cacheDir = await Directory.systemTemp.createTemp('hcs_serverless_'); + origin = await TestOrigin.start(payload); + }); + + tearDown(() async { + if (HttpCacheManager.isInitialized) { + await HttpCacheManager.instance.dispose(); + } + await origin.close(); + if (cacheDir.existsSync()) await cacheDir.delete(recursive: true); + }); + + Future> readAll(StreamResponse response) async { + final bytes = []; + await for (final chunk in response.stream) { + bytes.addAll(chunk); + } + return bytes; + } + + test('init without a server binds no socket', () async { + // Hold a port, then ask the manager for it: with a server, init would fail + // to bind; without one, the port is never touched. + final held = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(held.close); + + final manager = await HttpCacheManager.init( + cacheDir: cacheDir, + port: held.port, + startServer: false, + ); + expect(manager.hasServer, isFalse); + expect(HttpCacheManager.instanceOrNull, same(manager)); + }); + + test('a stream downloads, caches and serves from cache without a server', + () async { + final manager = + await HttpCacheManager.init(cacheDir: cacheDir, startServer: false); + final source = origin.url('/audio.mp3'); + final stream = manager.createStream(source); + + // First read streams from the origin while caching. + final first = await stream.request(); + expect(await readAll(first), payload); + + final file = await stream.download(); + expect(await file.readAsBytes(), payload); + final originRequests = origin.requestCount; + + // A ranged read is now served from the cache file, not the origin. + final ranged = await stream.request(start: 100, end: 200); + expect(ranged.source, ResponseSource.cacheFile); + expect(await readAll(ranged), payload.sublist(100, 200)); + expect(origin.requestCount, originRequests); + + expect(manager.getExistingStream(source), same(stream)); + await stream.dispose(force: true); + + // A fresh stream for the same URL picks up the completed cache. + final again = manager.createStream(source); + final cached = await again.request(); + expect(cached.source, ResponseSource.cacheFile); + expect(await readAll(cached), payload); + expect(origin.requestCount, originRequests); + await again.dispose(force: true); + }); + + test('preCacheUrl works without a server', () async { + final manager = + await HttpCacheManager.init(cacheDir: cacheDir, startServer: false); + final file = await manager.preCacheUrl(origin.url('/pre.mp3')); + expect(await file.readAsBytes(), payload); + }); + + test('cache URLs throw CacheServerUnavailableException without a server', + () async { + final manager = + await HttpCacheManager.init(cacheDir: cacheDir, startServer: false); + final source = origin.url('/audio.mp3'); + + expect( + () => manager.getCacheUrl(source), + throwsA(isA() + .having((e) => e.sourceUrl, 'sourceUrl', source)), + ); + + final stream = manager.createStream(source); + expect(stream.hasCacheUrl, isFalse); + expect( + () => stream.cacheUrl, + throwsA(isA()), + ); + // A StateError, so existing `on StateError` handlers still catch it. + expect(() => stream.cacheUrl, throwsStateError); + expect(stream.toString(), contains('cacheUrl: null')); + await stream.dispose(force: true); + }); + + test('ensureActive does nothing without a server', () async { + final held = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(held.close); + final manager = await HttpCacheManager.init( + cacheDir: cacheDir, + port: held.port, + startServer: false, + ); + + // Completes without trying to start or rebind a server on the held port. + await manager.ensureActive(); + await manager.ensureActive(); + expect(manager.hasServer, isFalse); + + // Streams still work afterwards. + final file = await manager.preCacheUrl(origin.url('/after.mp3')); + expect(await file.readAsBytes(), payload); + }); + + test('dispose works without a server', () async { + final manager = + await HttpCacheManager.init(cacheDir: cacheDir, startServer: false); + final stream = manager.createStream(origin.url('/audio.mp3')); + await stream.download(); + + await manager.dispose(); + expect(manager.isDisposed, isTrue); + expect(HttpCacheManager.isInitialized, isFalse); + await stream.future.timeout(const Duration(seconds: 5)); + expect(stream.isDisposed, isTrue); + expect(() => manager.ensureActive(), + throwsA(isA())); + + // A disposed serverless manager can be replaced by one with a server. + final next = await HttpCacheManager.init(cacheDir: cacheDir); + expect(next.hasServer, isTrue); + }); + + test('init after dispose returns a new manager', () async { + // Serverless init with a cacheDir completes without an await; the pending + // init future must still be cleared so the next init does not hand back + // the disposed manager. + final first = + await HttpCacheManager.init(cacheDir: cacheDir, startServer: false); + await first.dispose(); + final second = await HttpCacheManager.init( + config: GlobalCacheConfig(cacheDirectory: cacheDir), + startServer: false, + ); + expect(second, isNot(same(first))); + expect(second.isDisposed, isFalse); + expect(HttpCacheManager.instance, same(second)); + }); + + test('concurrent inits share one manager', () async { + final results = await Future.wait([ + HttpCacheManager.init(cacheDir: cacheDir, startServer: false), + HttpCacheManager.init(cacheDir: cacheDir, startServer: false), + ]); + expect(results[0], same(results[1])); + }); + + test('the default init still starts the server', () async { + final manager = await HttpCacheManager.init(cacheDir: cacheDir); + expect(manager.hasServer, isTrue); + final source = origin.url('/audio.mp3'); + final stream = manager.createStream(source); + expect(stream.hasCacheUrl, isTrue); + expect(stream.cacheUrl, manager.getCacheUrl(source)); + expect(stream.cacheUrl.host, InternetAddress.loopbackIPv4.address); + await manager.ensureActive(); + }); +}