LocalSend Onboarding LabChapter III — Shared Isolate & Protocol DTO Layer0/6Contents
Chapter III

Shared Isolate & Protocol DTO Layer

The common package is the shared spine of LocalSend: it defines the wire DTOs (Device, FileDto, register/prepare-upload payloads) that every subsystem passes around, and it implements a parent/child isolate framework that runs discovery (multicast + HTTP scan) and file upload work off the main Flutter isolate. A new engineer should understand IsolateConnector/startIsolate as the generic transport, IsolateTask/IsolateTaskResult as the request-response envelope, and the dart_mappable DTOs as the protocol contract that must stay backward compatible across LocalSend versions.


Parent isolate spawns and talks to dedicated child isolates
Shared DTO layercommon/lib/model/dto/file_dto.dart
Multicast Discovery Isolate…/multicast_discovery_isolate.dart
HTTP Scan Discovery Isolate…/http_scan_discovery_isolate.dart
HTTP Upload Isolate Pool (x2)…rent/parent_isolate_provider.dart
Main Flutter Isolate…rent/parent_isolate_provider.dart
Figure I · Click a node to see what it does, where it lives in the code, and its labeled connections — the annotation opens beside it.
walkthrough

IsolateSetupAction spins up every child isolate with a shared SyncState snapshot

common/lib/src/isolate/parent/parent_isolate_provider.dart · lines 67124
lines 67–74

Spawns the HTTP scan discovery isolate via the generic startIsolate<R,S,P> helper, handing it a fresh InitialData snapshot of the current SyncState and the root logger's level.

lines 75–82

Spawns a second, independent isolate for multicast discovery using the same InitialData pattern — each isolate gets its own copy, not a shared reference.

lines 83–108

Starts a fixed-size pool of upload isolates (_uploadIsolateCount = 2) and, if a URI content stream resolver was supplied, immediately pushes it into each one with a sentinel task id of -1.

lines 109–115

Awaits all upload isolates together and folds every IsolateConnector into a new ParentIsolateState via copyWith — this is the state the rest of the app reads to send tasks to the isolates.

  Future<ParentIsolateState> reduce() async {    final httpScanDiscovery = await startIsolate<IsolateTaskStreamResult<Device>, SendToIsolateData<IsolateTask<HttpScanTask>>, InitialData>(      task: setupHttpScanDiscoveryIsolate,      param: InitialData(        syncState: state.syncState,        logLevel: Logger.root.level,      ),    );     final multicastDiscovery = await startIsolate<Device, SendToIsolateData<MulticastTask>, InitialData>(      task: setupMulticastDiscoveryIsolate,      param: InitialData(        syncState: state.syncState,        logLevel: Logger.root.level,      ),    );     final httpUploadIsolates = List.generate(      _uploadIsolateCount,      (index) async {        final httpUpload = await startIsolate<IsolateTaskStreamResult<double>, SendToIsolateData<IsolateTask<BaseHttpUploadTask>>, InitialData>(          task: setupHttpUploadIsolate,          param: InitialData(            syncState: state.syncState,            logLevel: Logger.root.level,          ),        );         if (uriContentStreamResolver != null) {          httpUpload.sendToIsolate(SendToIsolateData(            syncState: null,            data: IsolateTask(              id: -1,              data: HttpUploadSetContentStreamResolverTask(resolver: uriContentStreamResolver!),            ),          ));        }         return httpUpload;      },      growable: false,    );     return state.copyWith(      httpScanDiscovery: httpScanDiscovery,      multicastDiscovery: multicastDiscovery,      httpUpload: await Future.wait(httpUploadIsolates),    );  }
step 1 of 4
class FileDtoMapper extends SimpleMapper<FileDto> {  const FileDtoMapper();   @override  FileDto decode(dynamic value) {    final map = value as Map<String, dynamic>;    final String rawFileType = map['fileType'] as String;    final FileType fileType;    if (rawFileType.contains('/')) {      // parse mime      fileType = decodeFromMime(rawFileType);    } else {      // parse legacy enum to internal internal enum      fileType = FileType.values.firstWhereOrNull((e) => e.name == rawFileType) ?? FileType.other;    }     return FileDto(      id: map['id'] as String,      fileName: map['fileName'] as String,      size: map['size'] as int,      fileType: fileType,      hash: map['hash'] as String?,      preview: map['preview'] as String?,      metadata: switch (map['metadata']) {        Map<String, dynamic> metadata => FileMetadataMapper.fromJson(metadata),        _ => null,      },    );  }   @override  dynamic encode(FileDto self) {    return {      'id': self.id,      'fileName': self.fileName,      'size': self.size,      'fileType': self.lookupMime(),      if (self.hash != null) 'hash': self.hash,      if (self.preview != null) 'preview': self.preview,      if (self.metadata != null) 'metadata': self.metadata!.toJson(),    };  }}
common/lib/model/dto/file_dto.dart · lines 60102
Figure II · decode() accepts either a MIME string or a legacy FileType enum name, while encode() always emits MIME via lookupMime() — a one-way migration that lets current clients keep interoperating with older LocalSend peers still sending enum-style fileType values.
Figure III · data modelFileDto and the DTOs built on top of itcommon/lib/model/dto/file_dto.dart
{
: {
: ""a1b2c3"",
: FileType,
: FileMetadata?
},
: null
}
checkpoint

Check your understanding

Answered in place — nothing is graded, everything is explained. 0 / 3 passed

Multiple discovery/upload requests can be in flight on the same child isolate at once. What lets the main isolate route each streamed result back to the correct caller?

Why does FileDto's mapper decode() branch to handle both MIME strings and legacy enum names for fileType?

How does a newly spawned child isolate learn the app's current settings (alias, port, network whitelist) without direct access to the main isolate's providers?