diff --git a/lib/providers/navigation_geometry_provider.dart b/lib/providers/navigation_geometry_provider.dart index 1d469d15..d1f50ca7 100644 --- a/lib/providers/navigation_geometry_provider.dart +++ b/lib/providers/navigation_geometry_provider.dart @@ -56,13 +56,13 @@ Future loadNavigationGeometry(MapValue map, )); } -NavigationGeometryMap _decodeNavigation( +Future _decodeNavigation( ({ MapValue map, Uint8List bytes, Offset defenseOffset, - }) source) { - final decoded = jsonDecode(utf8.decode(decodeWorldGzip(source.bytes))); + }) source) async { + final decoded = jsonDecode(utf8.decode(await inflateWorldGzip(source.bytes))); if (decoded is! Map || decoded['map'] != source.map.name) { throw const FormatException('Navigation geometry map mismatch.'); } diff --git a/lib/providers/svg_height_runtime_provider.dart b/lib/providers/svg_height_runtime_provider.dart index fd05a21f..77c3461e 100644 --- a/lib/providers/svg_height_runtime_provider.dart +++ b/lib/providers/svg_height_runtime_provider.dart @@ -258,8 +258,8 @@ Future _loadSide(Uint8List source, List artworkBytes, return model; } -Map _decodeModel(Uint8List source) { - final decoded = jsonDecode(utf8.decode(decodeWorldGzip(source))); +Future> _decodeModel(Uint8List source) async { + final decoded = jsonDecode(utf8.decode(await inflateWorldGzip(source))); if (decoded is! Map) { throw const FormatException('SVG sightline asset must be an object.'); } diff --git a/lib/view_cone/vision_world_gzip.dart b/lib/view_cone/vision_world_gzip.dart index 863c5809..a2dead26 100644 --- a/lib/view_cone/vision_world_gzip.dart +++ b/lib/view_cone/vision_world_gzip.dart @@ -2,14 +2,13 @@ import 'dart:typed_data'; import 'package:archive/archive.dart'; +import 'vision_world_gzip_web.dart' + if (dart.library.io) 'vision_world_gzip_io.dart' as platform; + /// archive uses native zlib on desktop and its Dart decoder on web. The SDK /// accepts partial streams, so untrusted blocks also need their complete footer. Uint8List decodeWorldGzip(Uint8List compressed, {bool verifyChecksum = true}) { - if (compressed.length < 18 || - compressed[0] != 0x1f || - compressed[1] != 0x8b) { - throw const FormatException('Invalid world gzip header.'); - } + checkWorldGzipHeader(compressed); final decoded = const GZipDecoder().decodeBytes(compressed, verify: true); final footer = ByteData.sublistView(compressed, compressed.length - 8); if (footer.getUint32(4, Endian.little) != decoded.length || @@ -19,3 +18,17 @@ Uint8List decodeWorldGzip(Uint8List compressed, {bool verifyChecksum = true}) { } return decoded; } + +/// [decodeWorldGzip] with the same checks. On the web the browser's own gzip +/// inflates and verifies the data: there the Dart decoder and CRC run on the +/// UI thread and take about 0.4 s per side of a large map. +Future inflateWorldGzip(Uint8List compressed) => + platform.inflateWorldGzip(compressed); + +void checkWorldGzipHeader(Uint8List compressed) { + if (compressed.length < 18 || + compressed[0] != 0x1f || + compressed[1] != 0x8b) { + throw const FormatException('Invalid world gzip header.'); + } +} diff --git a/lib/view_cone/vision_world_gzip_io.dart b/lib/view_cone/vision_world_gzip_io.dart new file mode 100644 index 00000000..fdec57f6 --- /dev/null +++ b/lib/view_cone/vision_world_gzip_io.dart @@ -0,0 +1,7 @@ +import 'dart:typed_data'; + +import 'vision_world_gzip.dart'; + +/// Desktop's zlib is native and fast; it runs in a background isolate. +Future inflateWorldGzip(Uint8List compressed) async => + decodeWorldGzip(compressed); diff --git a/lib/view_cone/vision_world_gzip_web.dart b/lib/view_cone/vision_world_gzip_web.dart new file mode 100644 index 00000000..62ebc8aa --- /dev/null +++ b/lib/view_cone/vision_world_gzip_web.dart @@ -0,0 +1,47 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +import 'vision_world_gzip.dart'; + +@JS('DecompressionStream') +extension type _DecompressionStream._(JSObject _) implements JSObject { + external factory _DecompressionStream(String format); +} + +extension type _ReadableStream._(JSObject _) implements JSObject { + external _ReadableStream pipeThrough(JSObject transform); +} + +@JS('Response') +extension type _Response._(JSObject _) implements JSObject { + external factory _Response(JSAny body); + external _ReadableStream get body; + external JSPromise arrayBuffer(); +} + +/// The browser's gzip checks what [decodeWorldGzip] checks: it rejects a +/// wrong CRC-32 or length, a stream that ends before its footer, and bytes +/// after it. The length is compared again here so a short read cannot pass. +Future inflateWorldGzip(Uint8List compressed) async { + // Firefox before 113 and Safari before 16.4 lack it; they keep the slow path. + if (!globalContext.has('DecompressionStream')) { + return decodeWorldGzip(compressed); + } + checkWorldGzipHeader(compressed); + final Uint8List decoded; + try { + final inflated = _Response(compressed.toJS) + .body + .pipeThrough(_DecompressionStream('gzip')); + decoded = + (await _Response(inflated).arrayBuffer().toDart).toDart.asUint8List(); + } on Object { + throw const FormatException('Invalid world gzip checksum or length.'); + } + final footer = ByteData.sublistView(compressed, compressed.length - 8); + if (footer.getUint32(4, Endian.little) != decoded.length) { + throw const FormatException('Invalid world gzip checksum or length.'); + } + return decoded; +}