devfs.dart 22 KB
Newer Older
1 2 3 4 5 6 7
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';
import 'dart:convert' show BASE64, UTF8;

8 9
import 'package:json_rpc_2/json_rpc_2.dart' as rpc;

10
import 'asset.dart';
11
import 'base/context.dart';
12
import 'base/file_system.dart';
13
import 'base/io.dart';
14
import 'build_info.dart';
15 16
import 'dart/package_map.dart';
import 'globals.dart';
17
import 'vmservice.dart';
18

19 20
typedef void DevFSProgressReporter(int progress, int max);

21 22 23
class DevFSConfig {
  /// Should DevFS assume that symlink targets are stable?
  bool cacheSymlinks = false;
24 25
  /// Should DevFS assume that there are no symlinks to directories?
  bool noDirectorySymlinks = false;
26 27 28 29
}

DevFSConfig get devFSConfig => context[DevFSConfig];

30 31 32
/// Common superclass for content copied to the device.
abstract class DevFSContent {
  bool _exists = true;
33

34 35 36
  /// Return `true` if this is the first time this method is called
  /// or if the entry has been modified since this method was last called.
  bool get isModified;
37

38 39 40 41 42 43 44 45 46
  int get size;

  Future<List<int>> contentsAsBytes();

  Stream<List<int>> contentsAsStream();

  Stream<List<int>> contentsAsCompressedStream() {
    return contentsAsStream().transform(GZIP.encoder);
  }
47 48 49

  /// Return the list of files this content depends on.
  List<String> get fileDependencies => <String>[];
50 51 52 53 54
}

// File content to be copied to the device.
class DevFSFileContent extends DevFSContent {
  DevFSFileContent(this.file);
55

56
  final FileSystemEntity file;
57
  FileSystemEntity _linkTarget;
58
  FileStat _fileStat;
59

60 61 62
  File _getFile() {
    if (_linkTarget != null) {
      return _linkTarget;
63
    }
64 65 66
    if (file is Link) {
      // The link target.
      return fs.file(file.resolveSymbolicLinksSync());
67
    }
68
    return file;
69 70
  }

71
  void _stat() {
72 73 74 75 76
    if (_linkTarget != null) {
      // Stat the cached symlink target.
      _fileStat = _linkTarget.statSync();
      return;
    }
77
    _fileStat = file.statSync();
78
    if (_fileStat.type == FileSystemEntityType.LINK) {
79
      // Resolve, stat, and maybe cache the symlink target.
80 81
      final String resolved = file.resolveSymbolicLinksSync();
      final FileSystemEntity linkTarget = fs.file(resolved);
82 83 84 85 86
      // Stat the link target.
      _fileStat = linkTarget.statSync();
      if (devFSConfig.cacheSymlinks) {
        _linkTarget = linkTarget;
      }
87
    }
88
  }
89

90 91 92
  @override
  List<String> get fileDependencies => <String>[_getFile().path];

93 94
  @override
  bool get isModified {
95
    final FileStat _oldFileStat = _fileStat;
96 97
    _stat();
    return _oldFileStat == null || _fileStat.modified.isAfter(_oldFileStat.modified);
98 99
  }

100 101 102 103 104
  @override
  int get size {
    if (_fileStat == null)
      _stat();
    return _fileStat.size;
105
  }
106

107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
  @override
  Future<List<int>> contentsAsBytes() => _getFile().readAsBytes();

  @override
  Stream<List<int>> contentsAsStream() => _getFile().openRead();
}

/// Byte content to be copied to the device.
class DevFSByteContent extends DevFSContent {
  DevFSByteContent(this._bytes);

  List<int> _bytes;

  bool _isModified = true;

  List<int> get bytes => _bytes;

124 125
  set bytes(List<int> value) {
    _bytes = value;
126
    _isModified = true;
127 128
  }

129 130 131
  /// Return `true` only once so that the content is written to the device only once.
  @override
  bool get isModified {
132
    final bool modified = _isModified;
133 134
    _isModified = false;
    return modified;
135
  }
136 137 138 139 140 141 142 143 144 145

  @override
  int get size => _bytes.length;

  @override
  Future<List<int>> contentsAsBytes() async => _bytes;

  @override
  Stream<List<int>> contentsAsStream() =>
      new Stream<List<int>>.fromIterable(<List<int>>[_bytes]);
146 147
}

148 149 150 151 152 153 154 155
/// String content to be copied to the device.
class DevFSStringContent extends DevFSByteContent {
  DevFSStringContent(String string) : _string = string, super(UTF8.encode(string));

  String _string;

  String get string => _string;

156 157
  set string(String value) {
    _string = value;
158 159 160 161
    super.bytes = UTF8.encode(_string);
  }

  @override
162 163
  set bytes(List<int> value) {
    string = UTF8.decode(value);
164 165
  }
}
166 167 168 169 170

/// Abstract DevFS operations interface.
abstract class DevFSOperations {
  Future<Uri> create(String fsName);
  Future<dynamic> destroy(String fsName);
171 172
  Future<dynamic> writeFile(String fsName, Uri deviceUri, DevFSContent content);
  Future<dynamic> deleteFile(String fsName, Uri deviceUri);
173 174 175
}

/// An implementation of [DevFSOperations] that speaks to the
176
/// vm service.
177
class ServiceProtocolDevFSOperations implements DevFSOperations {
178
  final VMService vmService;
179

180
  ServiceProtocolDevFSOperations(this.vmService);
181 182 183

  @override
  Future<Uri> create(String fsName) async {
184
    final Map<String, dynamic> response = await vmService.vm.createDevFS(fsName);
185 186 187 188 189
    return Uri.parse(response['uri']);
  }

  @override
  Future<dynamic> destroy(String fsName) async {
190
    await vmService.vm.deleteDevFS(fsName);
191 192 193
  }

  @override
194
  Future<dynamic> writeFile(String fsName, Uri deviceUri, DevFSContent content) async {
195 196
    List<int> bytes;
    try {
197
      bytes = await content.contentsAsBytes();
198 199 200
    } catch (e) {
      return e;
    }
201
    final String fileContents = BASE64.encode(bytes);
202
    try {
203 204 205 206
      return await vmService.vm.invokeRpcRaw(
        '_writeDevFSFile',
        params: <String, dynamic> {
          'fsName': fsName,
207
          'uri': deviceUri.toString(),
208 209 210 211
          'fileContents': fileContents
        },
      );
    } catch (error) {
212
      printTrace('DevFS: Failed to write $deviceUri: $error');
213
    }
214 215
  }

216
  @override
217
  Future<dynamic> deleteFile(String fsName, Uri deviceUri) async {
218 219
    // TODO(johnmccutchan): Add file deletion to the devFS protocol.
  }
220 221
}

222 223 224 225 226 227 228
class DevFSException implements Exception {
  DevFSException(this.message, [this.error, this.stackTrace]);
  final String message;
  final dynamic error;
  final StackTrace stackTrace;
}

229
class _DevFSHttpWriter {
230
  _DevFSHttpWriter(this.fsName, VMService serviceProtocol)
231 232 233 234 235 236
      : httpAddress = serviceProtocol.httpAddress;

  final String fsName;
  final Uri httpAddress;

  static const int kMaxInFlight = 6;
237
  static const int kMaxRetries = 3;
238 239

  int _inFlight = 0;
240
  Map<Uri, DevFSContent> _outstanding;
241
  Completer<Null> _completer;
242
  HttpClient _client;
243 244 245
  int _done;
  int _max;

246
  Future<Null> write(Map<Uri, DevFSContent> entries,
247
                     {DevFSProgressReporter progressReporter}) async {
248
    _client = new HttpClient();
249 250
    _client.maxConnectionsPerHost = kMaxInFlight;
    _completer = new Completer<Null>();
251
    _outstanding = new Map<Uri, DevFSContent>.from(entries);
252 253 254 255 256 257 258 259 260
    _done = 0;
    _max = _outstanding.length;
    _scheduleWrites(progressReporter);
    await _completer.future;
    _client.close();
  }

  void _scheduleWrites(DevFSProgressReporter progressReporter) {
    while (_inFlight < kMaxInFlight) {
261
      if (_outstanding.isEmpty) {
262 263 264
        // Finished.
        break;
      }
265 266
      final Uri deviceUri = _outstanding.keys.first;
      final DevFSContent content = _outstanding.remove(deviceUri);
267
      _scheduleWrite(deviceUri, content, progressReporter);
268 269 270 271
      _inFlight++;
    }
  }

272
  Future<Null> _scheduleWrite(
273
    Uri deviceUri,
274 275 276 277
    DevFSContent content,
    DevFSProgressReporter progressReporter, [
    int retry = 0,
  ]) async {
Ryan Macnak's avatar
Ryan Macnak committed
278
    try {
279
      final HttpClientRequest request = await _client.putUrl(httpAddress);
280
      request.headers.removeAll(HttpHeaders.ACCEPT_ENCODING);
Ryan Macnak's avatar
Ryan Macnak committed
281
      request.headers.add('dev_fs_name', fsName);
282
      request.headers.add('dev_fs_uri_b64',
283
          BASE64.encode(UTF8.encode(deviceUri.toString())));
284
      final Stream<List<int>> contents = content.contentsAsCompressedStream();
Ryan Macnak's avatar
Ryan Macnak committed
285
      await request.addStream(contents);
286
      final HttpClientResponse response = await request.close();
287
      await response.drain<Null>();
288 289 290 291 292
    } on SocketException catch (socketException, stackTrace) {
      // We have one completer and can get up to kMaxInFlight errors.
      if (!_completer.isCompleted)
        _completer.completeError(socketException, stackTrace);
      return;
293
    } catch (e) {
294
      if (retry < kMaxRetries) {
295 296
        printTrace('Retrying writing "$deviceUri" to DevFS due to error: $e');
        _scheduleWrite(deviceUri, content, progressReporter, retry + 1);
297 298
        return;
      } else {
299
        printError('Error writing "$deviceUri" to DevFS: $e');
300
      }
Ryan Macnak's avatar
Ryan Macnak committed
301
    }
302 303 304 305 306
    if (progressReporter != null) {
      _done++;
      progressReporter(_done, _max);
    }
    _inFlight--;
307
    if ((_outstanding.isEmpty) && (_inFlight == 0)) {
308 309 310 311 312 313 314
      _completer.complete(null);
    } else {
      _scheduleWrites(progressReporter);
    }
  }
}

315 316
class DevFS {
  /// Create a [DevFS] named [fsName] for the local files in [directory].
317
  DevFS(VMService serviceProtocol,
318
        this.fsName,
319 320 321
        this.rootDirectory, {
        String packagesFilePath
      })
322
    : _operations = new ServiceProtocolDevFSOperations(serviceProtocol),
323
      _httpWriter = new _DevFSHttpWriter(fsName, serviceProtocol) {
324
    _packagesFilePath =
325
        packagesFilePath ?? fs.path.join(rootDirectory.path, kPackagesFileName);
326
  }
327 328 329

  DevFS.operations(this._operations,
                   this.fsName,
330 331 332 333 334
                   this.rootDirectory, {
                   String packagesFilePath,
      })
    : _httpWriter = null {
    _packagesFilePath =
335
        packagesFilePath ?? fs.path.join(rootDirectory.path, kPackagesFileName);
336
  }
337 338

  final DevFSOperations _operations;
339
  final _DevFSHttpWriter _httpWriter;
340 341
  final String fsName;
  final Directory rootDirectory;
342
  String _packagesFilePath;
343
  final Map<Uri, DevFSContent> _entries = <Uri, DevFSContent>{};
344
  final Set<String> assetPathsToEvict = new Set<String>();
345

346
  final List<Future<Map<String, dynamic>>> _pendingOperations =
347
      <Future<Map<String, dynamic>>>[];
348

349 350 351 352
  Uri _baseUri;
  Uri get baseUri => _baseUri;

  Future<Uri> create() async {
353
    printTrace('DevFS: Creating new filesystem on the device ($_baseUri)');
354 355 356 357 358 359 360 361 362 363
    try {
      _baseUri = await _operations.create(fsName);
    } on rpc.RpcException catch (rpcException) {
      // 1001 is kFileSystemAlreadyExists in //dart/runtime/vm/json_stream.h
      if (rpcException.code != 1001)
        rethrow;
      printTrace('DevFS: Creating failed. Destroying and trying again');
      await destroy();
      _baseUri = await _operations.create(fsName);
    }
364 365 366 367
    printTrace('DevFS: Created new filesystem on the device ($_baseUri)');
    return _baseUri;
  }

368 369 370
  Future<Null> destroy() async {
    printTrace('DevFS: Deleting filesystem on the device ($_baseUri)');
    await _operations.destroy(fsName);
371 372 373
    printTrace('DevFS: Deleted filesystem on the device ($_baseUri)');
  }

374 375
  /// Update files on the device and return the number of bytes sync'd
  Future<int> update({ DevFSProgressReporter progressReporter,
376
                           AssetBundle bundle,
377 378
                           bool bundleDirty: false,
                           Set<String> fileFilter}) async {
379 380 381 382 383 384
    // Mark all entries as possibly deleted.
    for (DevFSContent content in _entries.values) {
      content._exists = false;
    }

    // Scan workspace, packages, and assets
385
    printTrace('DevFS: Starting sync from $rootDirectory');
386
    logger.printTrace('Scanning project files');
387
    await _scanDirectory(rootDirectory,
388 389
                         recursive: true,
                         fileFilter: fileFilter);
390
    if (fs.isFileSync(_packagesFilePath)) {
391 392
      printTrace('Scanning package files');
      await _scanPackages(fileFilter);
393
    }
394
    if (bundle != null) {
395
      printTrace('Scanning asset files');
396 397 398
      bundle.entries.forEach((String archivePath, DevFSContent content) {
        _scanBundleEntry(archivePath, content, bundleDirty);
      });
399
    }
400

401
    // Handle deletions.
402
    printTrace('Scanning for deleted files');
403
    final String assetBuildDirPrefix = _asUriPath(getAssetBuildDirectory());
404
    final List<Uri> toRemove = <Uri>[];
405
    _entries.forEach((Uri deviceUri, DevFSContent content) {
406
      if (!content._exists) {
407
        final Future<Map<String, dynamic>> operation =
408
            _operations.deleteFile(fsName, deviceUri);
409 410
        if (operation != null)
          _pendingOperations.add(operation);
411 412
        toRemove.add(deviceUri);
        if (deviceUri.path.startsWith(assetBuildDirPrefix)) {
413
          final String archivePath = deviceUri.path.substring(assetBuildDirPrefix.length);
414 415
          assetPathsToEvict.add(archivePath);
        }
416
      }
417 418 419 420
    });
    if (toRemove.isNotEmpty) {
      printTrace('Removing deleted files');
      toRemove.forEach(_entries.remove);
421 422 423
      await Future.wait(_pendingOperations);
      _pendingOperations.clear();
    }
424

425 426
    // Update modified files
    int numBytes = 0;
427
    final Map<Uri, DevFSContent> dirtyEntries = <Uri, DevFSContent>{};
428
    _entries.forEach((Uri deviceUri, DevFSContent content) {
429
      String archivePath;
430 431
      if (deviceUri.path.startsWith(assetBuildDirPrefix))
        archivePath = deviceUri.path.substring(assetBuildDirPrefix.length);
432
      if (content.isModified || (bundleDirty && archivePath != null)) {
433
        dirtyEntries[deviceUri] = content;
434 435 436 437 438
        numBytes += content.size;
        if (archivePath != null)
          assetPathsToEvict.add(archivePath);
      }
    });
439
    if (dirtyEntries.isNotEmpty) {
440
      printTrace('Updating files');
441
      if (_httpWriter != null) {
442
        try {
443
          await _httpWriter.write(dirtyEntries,
444
                                  progressReporter: progressReporter);
445 446 447 448 449 450
        } on SocketException catch (socketException, stackTrace) {
          printTrace("DevFS sync failed. Lost connection to device: $socketException");
          throw new DevFSException('Lost connection to device.', socketException, stackTrace);
        } catch (exception, stackTrace) {
          printError("Could not update files on device: $exception");
          throw new DevFSException('Sync failed', exception, stackTrace);
451
        }
452 453
      } else {
        // Make service protocol requests for each.
454
        dirtyEntries.forEach((Uri deviceUri, DevFSContent content) {
455
          final Future<Map<String, dynamic>> operation =
456
              _operations.writeFile(fsName, deviceUri, content);
457 458
          if (operation != null)
            _pendingOperations.add(operation);
459
        });
460 461 462
        if (progressReporter != null) {
          final int max = _pendingOperations.length;
          int complete = 0;
463 464 465 466
          _pendingOperations.forEach((Future<dynamic> f) => f.whenComplete(() {
            // TODO(ianh): If one of the pending operations fail, we'll keep
            // calling progressReporter long after update() has completed its
            // future, assuming that doesn't crash the app.
467 468 469 470 471 472 473
            complete += 1;
            progressReporter(complete, max);
          }));
        }
        await Future.wait(_pendingOperations, eagerError: true);
        _pendingOperations.clear();
      }
474 475
    }

476
    printTrace('DevFS: Sync finished');
477
    return numBytes;
478 479
  }

480
  void _scanFile(Uri deviceUri, FileSystemEntity file) {
481
    final DevFSContent content = _entries.putIfAbsent(deviceUri, () => new DevFSFileContent(file));
482
    content._exists = true;
483 484
  }

485 486 487
  void _scanBundleEntry(String archivePath, DevFSContent content, bool bundleDirty) {
    // We write the assets into the AssetBundle working dir so that they
    // are in the same location in DevFS and the iOS simulator.
488
    final Uri deviceUri = fs.path.toUri(fs.path.join(getAssetBuildDirectory(), archivePath));
489

490
    _entries[deviceUri] = content;
491
    content._exists = true;
492 493
  }

494
  bool _shouldIgnore(Uri deviceUri) {
495
    final List<String> ignoredUriPrefixes = <String>['android/',
496 497 498 499 500
                                               _asUriPath(getBuildDirectory()),
                                               'ios/',
                                               '.pub/'];
    for (String ignoredUriPrefix in ignoredUriPrefixes) {
      if (deviceUri.path.startsWith(ignoredUriPrefix))
501 502 503 504 505
        return true;
    }
    return false;
  }

506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524
  bool _shouldSkip(FileSystemEntity file,
                   String relativePath,
                   Uri directoryUriOnDevice, {
                   bool ignoreDotFiles: true,
                   }) {
    if (file is Directory) {
      // Skip non-files.
      return true;
    }
    assert((file is Link) || (file is File));
    if (ignoreDotFiles && fs.path.basename(file.path).startsWith('.')) {
      // Skip dot files.
      return true;
    }
    return false;
  }

  Uri _directoryUriOnDevice(Uri directoryUriOnDevice,
                            Directory directory) {
525
    if (directoryUriOnDevice == null) {
526
      final String relativeRootPath = fs.path.relative(directory.path, from: rootDirectory.path);
527 528 529 530 531
      if (relativeRootPath == '.') {
        directoryUriOnDevice = new Uri();
      } else {
        directoryUriOnDevice = fs.path.toUri(relativeRootPath);
      }
532
    }
533 534 535 536 537 538 539 540 541 542 543 544
    return directoryUriOnDevice;
  }

  /// Scan all files from the [fileFilter] that are contained in [directory] and
  /// pass various filters (e.g. ignoreDotFiles).
  Future<bool> _scanFilteredDirectory(Set<String> fileFilter,
                                      Directory directory,
                                      {Uri directoryUriOnDevice,
                                       bool ignoreDotFiles: true}) async {
    directoryUriOnDevice =
        _directoryUriOnDevice(directoryUriOnDevice, directory);
    try {
545
      final String absoluteDirectoryPath = canonicalizePath(directory.path);
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583
      // For each file in the file filter.
      for (String filePath in fileFilter) {
        if (!filePath.startsWith(absoluteDirectoryPath)) {
          // File is not in this directory. Skip.
          continue;
        }
        final String relativePath =
          fs.path.relative(filePath, from: directory.path);
        final FileSystemEntity file = fs.file(filePath);
        if (_shouldSkip(file, relativePath, directoryUriOnDevice, ignoreDotFiles: ignoreDotFiles)) {
          continue;
        }
        final Uri deviceUri = directoryUriOnDevice.resolveUri(fs.path.toUri(relativePath));
        if (!_shouldIgnore(deviceUri))
          _scanFile(deviceUri, file);
      }
    } on FileSystemException catch (e) {
      _printScanDirectoryError(directory.path, e);
      return false;
    }
    return true;
  }

  /// Scan all files in [directory] that pass various filters (e.g. ignoreDotFiles).
  Future<bool> _scanDirectory(Directory directory,
                              {Uri directoryUriOnDevice,
                               bool recursive: false,
                               bool ignoreDotFiles: true,
                               Set<String> fileFilter}) async {
    directoryUriOnDevice = _directoryUriOnDevice(directoryUriOnDevice, directory);
    if ((fileFilter != null) && fileFilter.isNotEmpty) {
      // When the fileFilter isn't empty, we can skip crawling the directory
      // tree and instead use the fileFilter as the source of potential files.
      return _scanFilteredDirectory(fileFilter,
                                    directory,
                                    directoryUriOnDevice: directoryUriOnDevice,
                                    ignoreDotFiles: ignoreDotFiles);
    }
584
    try {
585
      final Stream<FileSystemEntity> files =
586 587
          directory.list(recursive: recursive, followLinks: false);
      await for (FileSystemEntity file in files) {
588 589
        if (!devFSConfig.noDirectorySymlinks && (file is Link)) {
          // Check if this is a symlink to a directory and skip it.
590 591 592 593 594 595 596
          try {
            final FileSystemEntityType linkType =
                fs.statSync(file.resolveSymbolicLinksSync()).type;
            if (linkType == FileSystemEntityType.DIRECTORY)
              continue;
          } on FileSystemException catch (e) {
            _printScanDirectoryError(file.path, e);
597 598 599
            continue;
          }
        }
600
        final String relativePath =
601
          fs.path.relative(file.path, from: directory.path);
602
        if (_shouldSkip(file, relativePath, directoryUriOnDevice, ignoreDotFiles: ignoreDotFiles)) {
603 604
          continue;
        }
605
        final Uri deviceUri = directoryUriOnDevice.resolveUri(fs.path.toUri(relativePath));
606 607
        if (!_shouldIgnore(deviceUri))
          _scanFile(deviceUri, file);
608
      }
609 610
    } on FileSystemException catch (e) {
      _printScanDirectoryError(directory.path, e);
611 612 613 614
      return false;
    }
    return true;
  }
615

616 617 618 619 620 621 622 623
  void _printScanDirectoryError(String path, Exception e) {
    printError(
        'Error while scanning $path.\n'
        'Hot Reload might not work until the following error is resolved:\n'
        '$e\n'
    );
  }

624 625
  Future<Null> _scanPackages(Set<String> fileFilter) async {
    StringBuffer sb;
626
    final PackageMap packageMap = new PackageMap(_packagesFilePath);
627 628

    for (String packageName in packageMap.map.keys) {
629
      final Uri packageUri = packageMap.map[packageName];
630
      final String packagePath = fs.path.fromUri(packageUri);
631
      final Directory packageDirectory = fs.directory(packageUri);
632
      Uri directoryUriOnDevice = fs.path.toUri(fs.path.join('packages', packageName) + fs.path.separator);
633 634 635 636 637 638
      bool packageExists = packageDirectory.existsSync();

      if (!packageExists) {
        // If the package directory doesn't exist at all, we ignore it.
        continue;
      }
639 640 641

      if (fs.path.isWithin(rootDirectory.path, packagePath)) {
        // We already scanned everything under the root directory.
642 643 644
        directoryUriOnDevice = fs.path.toUri(
            fs.path.relative(packagePath, from: rootDirectory.path) + fs.path.separator
        );
645 646 647
      } else {
        packageExists =
            await _scanDirectory(packageDirectory,
648
                                 directoryUriOnDevice: directoryUriOnDevice,
649 650 651
                                 recursive: true,
                                 fileFilter: fileFilter);
      }
652 653
      if (packageExists) {
        sb ??= new StringBuffer();
654
        sb.writeln('$packageName:$directoryUriOnDevice');
655 656 657
      }
    }
    if (sb != null) {
658
      final DevFSContent content = _entries[fs.path.toUri('.packages')];
659 660 661 662
      if (content is DevFSStringContent && content.string == sb.toString()) {
        content._exists = true;
        return;
      }
663
      _entries[fs.path.toUri('.packages')] = new DevFSStringContent(sb.toString());
664 665
    }
  }
666
}
667 668
/// Converts a platform-specific file path to a platform-independent Uri path.
String _asUriPath(String filePath) => fs.path.toUri(filePath).path + '/';