import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:archive/archive_io.dart'; import '../data/content_policy.dart'; import 'package:xml/xml.dart'; /// An owned resource, never a remote reference in a published manifest. class OfflineResource { OfflineResource({ required this.url, required this.filename, this.offset, this.length, this.key = false, this.bytes = 0, this.digest = '', }); final String url, filename; final int? offset, length; final bool key; int bytes; String digest; factory OfflineResource.fromJson(Map j) => OfflineResource( url: j['url'] as String, filename: j['filename'] as String, offset: j['offset'] as int?, length: j['length'] as int?, key: j['key'] == true, bytes: j['bytes'] as int? ?? 0, digest: j['digest'] as String? ?? '', ); Map toJson() => { 'url': url, 'filename': filename, 'offset': offset, 'length': length, 'key': key, 'bytes': bytes, 'digest': digest, }; } class OfflinePackage { OfflinePackage({ required this.id, required this.url, required this.directory, required this.manifest, required this.resources, required this.manifests, this.metadata = const {}, this.status = 'paused', this.error = '', }); final String id, url, directory, manifest; final List resources; final Map manifests; final Map metadata; String status, error; int get receivedBytes => resources.fold(0, (n, r) => n + r.bytes); double get progress => resources.isEmpty ? 0 : resources.where((r) => r.digest.isNotEmpty).length / resources.length; String get path => '$directory/$manifest'; factory OfflinePackage.fromJson(Map j, String directory) => OfflinePackage( id: j['id'] as String, url: j['url'] as String, directory: directory, manifest: j['manifest'] as String, resources: (j['resources'] as List) .map( (r) => OfflineResource.fromJson(Map.from(r as Map)), ) .toList(), manifests: Map.from(j['manifests'] as Map), metadata: Map.from(j['metadata'] as Map? ?? {}), status: j['status'] as String, error: j['error'] as String? ?? '', ); Map toJson() => { 'version': 1, 'id': id, 'url': url, 'manifest': manifest, 'resources': resources.map((r) => r.toJson()).toList(), 'manifests': manifests, 'metadata': metadata, 'status': status, 'error': error, }; } class _Stopped implements Exception {} /// Durable, bounded package orchestration. Runs only while Dart is alive: /// interrupted work recovers paused, never as a false completed download. /// Each completed resource has a length AND SHA-256 check before reuse. class SegmentedDownload { SegmentedDownload({ required this.root, int parallel = 3, this.transfer, this.cancelTransfers, }) : parallel = parallel.clamp(1, 8); final Directory root; int parallel; final Future Function(OfflinePackage, OfflineResource, String)? transfer; final Future Function(String)? cancelTransfers; final Map _running = {}; final Map _stopped = {}; final Map> _writes = {}; static bool _safeName(String s) => RegExp(r'^[a-zA-Z0-9_-]+$').hasMatch(s); Directory _directory(String id) { if (!_safeName(id)) throw ArgumentError('Invalid package id'); return Directory('${root.path}/$id'); } static Uri _remote(String value) { final uri = Uri.parse(value); if (!['http', 'https'].contains(uri.scheme) || uri.host.isEmpty || uri.userInfo.isNotEmpty || !ContentPolicy.allowsUrl(value)) { throw UnsupportedError( 'Only authorized public HTTP(S) media is supported.', ); } return uri; } Future _save(OfflinePackage p) { final previous = _writes[p.id] ?? Future.value(); final next = previous.catchError((Object _) {}).then((_) async { final file = File('${p.directory}/package.json'); final tmp = File('${file.path}.tmp'); await tmp.writeAsString(jsonEncode(p.toJson()), flush: true); await tmp.rename(file.path); }); _writes[p.id] = next; return next; } Future load(String id) async { final dir = _directory(id); final p = OfflinePackage.fromJson( jsonDecode(await File('${dir.path}/package.json').readAsString()) as Map, dir.path, ); if (p.id != id || p.resources.any((r) => !_local(r.filename)) || p.manifests.keys.any((s) => !_local(s)) || !p.manifests.containsKey(p.manifest)) { throw const FormatException('Invalid offline package index.'); } return p; } static bool _local(String name) => RegExp(r'^[a-zA-Z0-9_.-]+$').hasMatch(name) && !name.startsWith('.'); Future> recover() async { if (!await root.exists()) return []; final result = []; await for (final dir in root.list(followLinks: false)) { if (dir is! Directory || !_safeName(dir.uri.pathSegments.where((s) => s.isNotEmpty).last)) { continue; } try { final p = await load( dir.uri.pathSegments.where((s) => s.isNotEmpty).last, ); if (_running.containsKey(p.id)) { result.add(p); continue; } if (p.status == 'running' || p.status == 'enqueued') { p.status = 'paused'; await _save(p); } if (p.status == 'complete' && !await validate(p.id)) { p.status = 'failed'; p.error = 'Offline files are missing or damaged; retry to repair.'; await _save(p); } result.add(p); } on FileSystemException { /* Unfinished planning is not a published package. */ } on FormatException { /* Corrupt indices never become playable entries. */ } } return result; } /// Own redirects so every request (including resources) passes the same policy. /// Return the effective URI: relative manifest references belong to its origin. Future<(Uri, HttpClientResponse)> _get( HttpClient client, Uri uri, { Map headers = const {}, }) async { for (var redirects = 0; ; redirects++) { final request = await client.getUrl(_remote(uri.toString())); request.followRedirects = false; headers.forEach(request.headers.set); final response = await request.close().timeout( const Duration(seconds: 30), ); if (!const [301, 302, 303, 307, 308].contains(response.statusCode)) { return (uri, response); } final location = response.headers.value(HttpHeaders.locationHeader); // Cancel rather than consuming an unbounded redirect response body. await response.listen((_) {}).cancel(); if (redirects >= 5) { throw const HttpException('Too many media redirects.'); } if (location == null || location.trim().isEmpty) { throw const HttpException('Media redirect has no Location.'); } uri = _remote(uri.resolve(location).toString()); } } Future<(Uri, String)> _text(HttpClient client, Uri uri) async { final (effectiveUri, response) = await _get(client, uri); if (response.statusCode != 200) { throw HttpException('Manifest HTTP ${response.statusCode}'); } final bytes = []; await for (final chunk in response.timeout(const Duration(seconds: 30))) { bytes.addAll(chunk); if (bytes.length > 4 * 1024 * 1024) { throw const FormatException('Manifest is too large.'); } } return (effectiveUri, utf8.decode(bytes)); } Future prepare({ required String id, required String url, String type = '', Map metadata = const {}, }) async { final uri = _remote(url); if (RegExp( r'drm|widevine|playready|fairplay|protected', caseSensitive: false, ).hasMatch(type)) { throw UnsupportedError('DRM/protected streams cannot be downloaded.'); } final directory = _directory(id); if (await directory.exists()) throw StateError('Package already exists.'); final client = HttpClient() ..connectionTimeout = const Duration(seconds: 30); final resources = []; final manifests = {}; try { final (effectiveUri, text) = await _text(client, uri); final seen = {}; String resource(Uri url, {int? offset, int? length, bool key = false}) { _remote(url.toString()); final identity = '$url:$offset:$length'; final prior = resources.where( (r) => '${r.url}:${r.offset}:${r.length}' == identity, ); if (prior.isNotEmpty) return prior.first.filename; if (resources.length >= 20000) { throw UnsupportedError('Too many offline resources.'); } final extension = key ? 'key' : (RegExp(r'\.(ts|m4s|mp4|aac|vtt|webvtt)$') .firstMatch(url.path) ?.group(1) ?? 'bin'); final name = 'resource_${resources.length}.$extension'; resources.add( OfflineResource( url: url.toString(), filename: name, offset: offset, length: length, key: key, ), ); return name; } Future playlist(Uri url, String content, int depth) async { if (depth > 4) { throw UnsupportedError('HLS playlist nesting is too deep.'); } if (seen.containsKey(url.toString())) return seen[url.toString()]!; final name = 'playlist_${seen.length}.m3u8'; seen[url.toString()] = name; final lines = const LineSplitter() .convert(content) .map((l) => l.trim()) .where((l) => l.isNotEmpty) .toList(); if (lines.isEmpty || lines.first != '#EXTM3U') { throw const FormatException('Invalid HLS playlist.'); } final output = ['#EXTM3U']; if (lines.any((l) => l.startsWith('#EXT-X-STREAM-INF:'))) { final variants = <(String, String, int)>[]; for (var i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF:')) { if (i + 1 >= lines.length || lines[i + 1].startsWith('#')) { throw const FormatException('Missing variant URI.'); } final attrs = _attributes(lines[i]); variants.add(( lines[i], lines[i + 1], int.tryParse(attrs['BANDWIDTH'] ?? '') ?? 0, )); } if (lines[i].startsWith('#EXT-X-SESSION-KEY:')) { throw UnsupportedError('HLS session keys are unsupported.'); } } variants.sort((a, b) => b.$3.compareTo(a.$3)); final variant = variants.first; final attrs = _attributes(variant.$1); for (final kind in ['AUDIO', 'SUBTITLES']) { final group = attrs[kind]; if (group == null) continue; final choices = lines .where((l) => l.startsWith('#EXT-X-MEDIA:')) .where((l) { final a = _attributes(l); return a['TYPE'] == kind && a['GROUP-ID'] == group; }) .toList(); if (choices.isEmpty) { throw UnsupportedError('Missing HLS $kind rendition.'); } final selected = choices.firstWhere( (l) => _attributes(l)['DEFAULT'] == 'YES', orElse: () => choices.first, ); final a = _attributes(selected); if (a['URI'] != null) { final child = url.resolve(a['URI']!); final (effectiveChild, childText) = await _text(client, child); final local = await playlist( effectiveChild, childText, depth + 1, ); output.add(_replaceAttribute(selected, 'URI', local)); } else if (kind == 'AUDIO') { output.add(selected); } else { throw UnsupportedError('Missing subtitle rendition URI.'); } } final child = url.resolve(variant.$2); final (effectiveChild, childText) = await _text(client, child); final local = await playlist(effectiveChild, childText, depth + 1); output.addAll([variant.$1, local]); } else { if (!lines.contains('#EXT-X-ENDLIST')) { throw UnsupportedError( 'Live/unbounded HLS is not an offline download.', ); } int? nextLength, nextOffset; var previousEnd = 0; String? previousUrl; var segments = 0; var encrypted = false, explicitIv = false; for (final line in lines.skip(1)) { if (line.startsWith('#EXT-X-BYTERANGE:')) { final parts = line.split(':').last.split('@'); nextLength = int.tryParse(parts[0]); nextOffset = parts.length > 1 ? int.tryParse(parts[1]) : null; if (nextLength == null || nextLength <= 0) { throw const FormatException('Invalid HLS byte range.'); } } else if (line.startsWith('#EXT-X-KEY:') || line.startsWith('#EXT-X-MAP:')) { final a = _attributes(line); final key = line.startsWith('#EXT-X-KEY:'); if (key && a['METHOD'] == 'NONE') { encrypted = false; explicitIv = false; output.add(line); continue; } if (!key && encrypted && !explicitIv) { throw UnsupportedError( 'Encrypted HLS init maps require an explicit IV.', ); } if (key) { encrypted = true; explicitIv = a['IV'] != null; if (explicitIv && !RegExp(r'^0[xX][0-9a-fA-F]{32}$').hasMatch(a['IV']!)) { throw UnsupportedError('Invalid AES-128 IV.'); } } if (key && (a['METHOD'] != 'AES-128' || (a['KEYFORMAT'] != null && a['KEYFORMAT'] != 'identity'))) { throw UnsupportedError( 'DRM/protected HLS encryption is unsupported.', ); } final ref = a['URI']; if (ref == null) { throw const FormatException('Missing HLS resource URI.'); } int? length, offset; if (a['BYTERANGE'] != null) { final parts = a['BYTERANGE']!.split('@'); length = int.tryParse(parts[0]); offset = parts.length == 2 ? int.tryParse(parts[1]) : null; if (length == null || length <= 0 || offset == null || offset < 0) { throw UnsupportedError( 'Map byte range requires an explicit valid offset.', ); } } final local = resource( url.resolve(ref), offset: offset, length: length, key: key, ); output.add( _replaceAttribute( line, 'URI', local, ).replaceAll(RegExp(r',?BYTERANGE="[^"]*"'), ''), ); } else if (!line.startsWith('#')) { final resolved = url.resolve(line); final offset = nextLength == null ? null : nextOffset ?? (previousUrl == resolved.toString() ? previousEnd : throw const FormatException( 'Implicit range has no preceding resource.', )); output.add( resource(resolved, offset: offset, length: nextLength), ); if (nextLength != null) previousEnd = offset! + nextLength; previousUrl = resolved.toString(); nextLength = null; nextOffset = null; segments++; } else { if (line.contains('URI=') || line.startsWith('#EXT-X-PART') || line.startsWith('#EXT-X-SKIP') || line.startsWith('#EXT-X-SESSION')) { throw UnsupportedError('Unsupported HLS resource extension.'); } output.add(line); } } if (segments == 0 || nextLength != null) { throw const FormatException('Empty or incomplete media playlist.'); } } manifests[name] = '${output.join('\n')}\n'; return name; } final String manifest; if (text.trimLeft().startsWith('#EXTM3U')) { manifest = await playlist(effectiveUri, text, 0); } else { manifest = 'offline.mpd'; manifests[manifest] = _dash( text, effectiveUri, (url, offset, length) => resource(url, offset: offset, length: length), ); } if (resources.isEmpty) { throw const FormatException('Empty offline package.'); } await directory.create(recursive: true); final p = OfflinePackage( id: id, url: url, directory: directory.path, manifest: manifest, resources: resources, manifests: manifests, metadata: metadata, ); await _save(p); return p; } finally { client.close(force: true); } } static String _dash( String text, Uri origin, String Function(Uri, int?, int?) resource, ) { if (text.contains(' a.name.local == 'href' || a.name.local == 'availabilityTimeOffset', )) { throw UnsupportedError('DRM/protected or unsupported DASH addressing.'); } } List children(XmlElement e, String tag) => e.childElements.where((x) => x.name.local == tag).toList(); XmlElement? one(XmlElement e, String tag) { final found = children(e, tag); if (found.length > 1) { throw UnsupportedError('Multiple DASH $tag entries are unsupported.'); } return found.isEmpty ? null : found.single; } Uri base(XmlElement element, Uri parent) { final node = one(element, 'BaseURL'); return node == null ? parent : _remote(parent.resolve(node.innerText.trim()).toString()); } double? seconds(String? value) { if (value == null) return null; final m = RegExp( r'^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$', ).firstMatch(value); if (m == null) throw UnsupportedError('Unsupported DASH duration.'); return (double.tryParse(m[1] ?? '') ?? 0) * 86400 + (double.tryParse(m[2] ?? '') ?? 0) * 3600 + (double.tryParse(m[3] ?? '') ?? 0) * 60 + (double.tryParse(m[4] ?? '') ?? 0); } final periods = children(mpd, 'Period'); if (periods.length != 1) { throw UnsupportedError('Exactly one finite DASH Period is supported.'); } final period = periods.single; if ((seconds(period.getAttribute('start')) ?? 0) != 0) { throw UnsupportedError('Nonzero DASH Period start is unsupported.'); } final duration = seconds(period.getAttribute('duration')) ?? seconds(mpd.getAttribute('mediaPresentationDuration')); final periodBase = base(period, base(mpd, origin)); final adaptations = children(period, 'AdaptationSet'); if (adaptations.isEmpty) { throw const FormatException('DASH has no adaptations.'); } for (final adaptation in adaptations) { final representations = children(adaptation, 'Representation'); if (representations.isEmpty) { throw UnsupportedError('DASH representation is missing.'); } representations.sort( (a, b) => (int.tryParse(b.getAttribute('bandwidth') ?? '') ?? 0) .compareTo(int.tryParse(a.getAttribute('bandwidth') ?? '') ?? 0), ); final rep = representations.first; final repBase = base(rep, base(adaptation, periodBase)); final chain = [mpd, period, adaptation, rep]; final templateAttrs = {}; XmlElement? template, list, timeline; for (final scope in chain) { final t = one(scope, 'SegmentTemplate'), l = one(scope, 'SegmentList'); if (t != null) { template = t; for (final a in t.attributes) { templateAttrs[a.name.local] = a.value; } timeline = one(t, 'SegmentTimeline') ?? timeline; } if (l != null) list = l; } if ((template == null) == (list == null)) { throw UnsupportedError( 'DASH requires one supported SegmentTemplate or SegmentList.', ); } final output = XmlElement(XmlName.parts('SegmentList')); int? integer(String? v) => v == null ? null : int.tryParse(v); int positive(String? v, int fallback) { final n = integer(v) ?? fallback; if (n <= 0) { throw const FormatException('Invalid DASH timescale/duration.'); } return n; } String local(String ref, String? byteRange) { int? offset, length; if (byteRange != null) { final m = RegExp(r'^(\d+)-(\d+)$').firstMatch(byteRange); if (m == null) { throw const FormatException('Invalid DASH byte range.'); } offset = int.parse(m[1]!); length = int.parse(m[2]!) - offset + 1; if (length <= 0) { throw const FormatException('Invalid DASH byte range.'); } } return resource(repBase.resolve(ref), offset, length); } void initialization(String ref, String? range) { output.children.add( XmlElement(XmlName.parts('Initialization'), [ XmlAttribute(XmlName.parts('sourceURL'), local(ref, range)), ]), ); } void segment(String ref, String? range) { output.children.add( XmlElement(XmlName.parts('SegmentURL'), [ XmlAttribute(XmlName.parts('media'), local(ref, range)), ]), ); } if (list != null) { for (final a in list.attributes) { if (![ 'timescale', 'duration', 'startNumber', 'presentationTimeOffset', ].contains(a.name.local)) { throw UnsupportedError('Unsupported DASH SegmentList attribute.'); } output.attributes.add(a.copy()); } final init = one(list, 'Initialization'); if (init != null) { final ref = init.getAttribute('sourceURL'); if (ref == null) { throw UnsupportedError( 'DASH initialization without sourceURL is unsupported.', ); } initialization(ref, init.getAttribute('range')); } final t = one(list, 'SegmentTimeline'); if (t != null) output.children.add(t.copy()); final segments = children(list, 'SegmentURL'); if (segments.isEmpty) { throw const FormatException('DASH segment list is empty.'); } for (final s in segments) { final ref = s.getAttribute('media'); if (ref == null || s.getAttribute('index') != null || s.getAttribute('indexRange') != null) { throw UnsupportedError('Unsupported DASH segment indexing.'); } segment(ref, s.getAttribute('mediaRange')); } if (t == null && list.getAttribute('duration') == null) { throw UnsupportedError('DASH list requires explicit timing.'); } } else { if (templateAttrs.keys.any( (k) => ![ 'timescale', 'duration', 'startNumber', 'presentationTimeOffset', 'initialization', 'media', ].contains(k), )) { throw UnsupportedError('Unsupported DASH template attributes.'); } final timescale = positive(templateAttrs['timescale'], 1); final start = integer(templateAttrs['startNumber']) ?? 1; final pto = integer(templateAttrs['presentationTimeOffset']) ?? 0; if (start < 0 || pto < 0) { throw const FormatException('Invalid DASH template timing.'); } output.setAttribute('timescale', '$timescale'); output.setAttribute('startNumber', '$start'); if (pto != 0) output.setAttribute('presentationTimeOffset', '$pto'); String expand(String pattern, int number, int time) { const escaped = '__DASH_DOLLAR__'; var value = pattern.replaceAll('\$\$', escaped); value = value.replaceAllMapped( RegExp( r'\$(RepresentationID|Bandwidth|Number|Time)(?:%0(\d+)d)?\$', ), (m) { final token = m[1]!; final replacement = switch (token) { 'RepresentationID' => rep.getAttribute('id'), 'Bandwidth' => rep.getAttribute('bandwidth'), 'Number' => '$number', _ => '$time', }; if (replacement == null) { throw UnsupportedError('Missing DASH $token.'); } final width = int.tryParse(m[2] ?? '') ?? 0; if (width > 20) { throw UnsupportedError('DASH number formatting too wide.'); } return replacement.padLeft(width, '0'); }, ); if (value.contains('\$')) { throw UnsupportedError('Unsupported DASH template token.'); } return value.replaceAll(escaped, '\$'); } final init = templateAttrs['initialization']; if (init != null) initialization(expand(init, start, 0), null); final media = templateAttrs['media']; if (media == null) { throw const FormatException('DASH media template is missing.'); } if (timeline != null) { var time = 0, number = start; final entries = children(timeline, 'S'); if (entries.isEmpty) { throw const FormatException('Empty DASH timeline.'); } for (final s in entries) { time = integer(s.getAttribute('t')) ?? time; final d = positive(s.getAttribute('d'), 0), r = integer(s.getAttribute('r')) ?? 0; if (time < 0 || r < 0 || r > 20000) { throw UnsupportedError( 'Unbounded/negative DASH timeline repeat is unsupported.', ); } for (var i = 0; i <= r; i++) { segment(expand(media, number++, time), null); time += d; } } output.children.add(timeline.copy()); } else { final d = positive(templateAttrs['duration'], 0); if (duration == null || duration <= 0) { throw UnsupportedError( 'DASH duration is required for finite templates.', ); } final count = (duration * timescale / d).ceil(); if (count > 20000) throw UnsupportedError('Too many DASH segments.'); output.setAttribute('duration', '$d'); for (var i = 0; i < count; i++) { segment(expand(media, start + i, pto + i * d), null); } } } // Keep representation codec metadata, but replace all remote addressing. for (final scope in [adaptation, rep]) { scope.children.removeWhere( (n) => n is XmlElement && [ 'SegmentTemplate', 'SegmentList', 'BaseURL', ].contains(n.name.local), ); } for (final other in representations.skip(1)) { adaptation.children.remove(other); } rep.children.add(output); } for (final scope in [mpd, period]) { scope.children.removeWhere( (n) => n is XmlElement && [ 'SegmentTemplate', 'SegmentList', 'BaseURL', ].contains(n.name.local), ); } // Never retain a fetch-capable extension/reference that we didn't package. for (final element in [mpd, ...mpd.descendantElements]) { if (element.name.local == 'BaseURL' || element.attributes.any( (a) => ['href', 'sourceURL', 'media'].contains(a.name.local) && (a.value.contains('://') || a.value.startsWith('/')), )) { throw UnsupportedError('Unpackaged DASH reference.'); } } return document.toXmlString(); } static Map _attributes(String line) { final attrs = {}; for (final match in RegExp( r'([A-Z0-9-]+)=("[^"]*"|[^,]*)', ).allMatches(line.substring(line.indexOf(':') + 1))) { final value = match[2]!; attrs[match[1]!] = value.startsWith('"') ? value.substring(1, value.length - 1) : value; } return attrs; } static String _replaceAttribute(String line, String key, String value) => line.replaceFirst(RegExp('$key=("[^"]*"|[^,]*)'), '$key="$value"'); Future _validResource(OfflinePackage p, OfflineResource r) async { if (r.bytes <= 0 || r.digest.isEmpty) return false; final f = File('${p.directory}/${r.filename}'); if (!await f.exists() || await f.length() != r.bytes) return false; return (await sha256.bind(f.openRead()).first).toString() == r.digest; } Future validate(String id) async { final p = await load(id); if (p.status != 'complete' || p.resources.isEmpty) return false; for (final r in p.resources) { if (!await _validResource(p, r)) return false; } for (final entry in p.manifests.entries) { final f = File('${p.directory}/${entry.key}'); if (!await f.exists() || await f.readAsString() != entry.value) { return false; } } return true; } Future run( String id, { void Function(OfflinePackage)? onProgress, }) async { if (_running.containsKey(id)) { throw StateError('Package is already running.'); } final p = await load(id); final client = HttpClient() ..connectionTimeout = const Duration(seconds: 30); _running[id] = client; _stopped.remove(id); p.status = 'running'; p.error = ''; await _save(p); onProgress?.call(p); Object? failure; var cursor = 0; Future worker() async { while (cursor < p.resources.length && failure == null && !_stopped.containsKey(id)) { final r = p.resources[cursor++]; try { if (await _validResource(p, r)) { onProgress?.call(p); continue; } r.bytes = 0; r.digest = ''; final tmp = File('${p.directory}/${r.filename}.part'); if (transfer != null) { await transfer!(p, r, tmp.path); } else { final (_, response) = await _get( client, _remote(r.url), headers: { HttpHeaders.acceptEncodingHeader: 'identity', if (r.offset != null) HttpHeaders.rangeHeader: 'bytes=${r.offset}-${r.offset! + r.length! - 1}', }, ); if (r.offset == null && response.statusCode != 200 || r.offset != null && response.statusCode != 206) { throw HttpException( 'Resource HTTP ${response.statusCode}; range authorization is required.', ); } if (r.offset != null) { final range = response.headers.value('content-range') ?? ''; if (!RegExp( '^bytes ${r.offset}-${r.offset! + r.length! - 1}/[0-9]+\$', ).hasMatch(range)) { throw const FormatException('Incorrect Content-Range.'); } } final sink = tmp.openWrite(); var count = 0; try { await for (final chunk in response.timeout( const Duration(seconds: 30), )) { if (_stopped.containsKey(id)) throw _Stopped(); count += chunk.length; if (r.length != null && count > r.length!) { throw const FormatException('Oversized range.'); } sink.add(chunk); } } finally { await sink.close(); } if (count == 0 || r.length != null && count != r.length || response.contentLength >= 0 && response.contentLength != count || r.key && count != 16) { throw const FormatException( 'Truncated/invalid offline resource.', ); } } final count = await tmp.length(); if (count == 0 || r.length != null && count != r.length || r.key && count != 16) { throw const FormatException('Invalid native resource length.'); } if (_stopped.containsKey(id)) throw _Stopped(); final digest = (await sha256.bind(tmp.openRead()).first).toString(); await tmp.rename('${p.directory}/${r.filename}'); r.bytes = count; r.digest = digest; await _save(p); onProgress?.call(p); } catch (e) { if (!_stopped.containsKey(id)) failure ??= e; } } } try { await Future.wait(List.generate(parallel, (_) => worker())); if (_stopped.containsKey(id)) { p.status = _stopped[id]!; } else if (failure != null) { p.status = 'failed'; p.error = 'Offline resource failed: $failure'; } else { for (final r in p.resources) { if (!await _validResource(p, r)) { throw StateError('Resource integrity check failed.'); } } for (final entry in p.manifests.entries) { await File('${p.directory}/${entry.key}') .writeAsString(entry.value, flush: true); } p.status = 'complete'; } } catch (e) { p.status = 'failed'; p.error = e.toString(); } finally { client.close(force: true); _running.remove(id); await _save(p); onProgress?.call(p); } return p; } Future stop(String id, {bool cancel = false}) async { _stopped[id] = cancel ? 'canceled' : 'paused'; final client = _running[id]; client?.close(force: true); if (cancelTransfers != null) await cancelTransfers!(id); if (client == null) { final p = await load(id); p.status = _stopped[id]!; await _save(p); } else { while (_running.containsKey(id)) { await Future.delayed(const Duration(milliseconds: 10)); } await _writes[id]; } } final _exporting = {}; Future export(String id) async { if (!_exporting.add(id)) { throw StateError('This package is already being exported.'); } try { if (!await validate(id)) { throw StateError('Only a complete validated package can be exported.'); } final p = await load(id); final dir = await Directory('${root.path}/exports') .create(recursive: true); final tmp = '${dir.path}/$id.zip.part'; final encoder = ZipFileEncoder() ..create(tmp, level: ZipFileEncoder.store); try { // Deliberately omit package.json: it contains original remote URL metadata. for (final name in { ...p.manifests.keys, ...p.resources.map((r) => r.filename), }) { await encoder.addFile(File('${p.directory}/$name'), name); } } finally { await encoder.close(); } final path = '${dir.path}/$id.zip'; await File(tmp).rename(path); return path; } finally { _exporting.remove(id); } } Future delete(String id) async { if (_running.containsKey(id)) await stop(id, cancel: true); await _directory(id).delete(recursive: true); } }