Why LocalSend Needs Both a Rust Core and a Flutter Shell
LocalSend's cross-platform behavior is part of its trust model, not merely a user-interface choice. The Flutter application decides how a person or policy should respond to a transfer; the Rust core owns the network, cryptographic, and session mechanics; an isolate boundary keeps that work away from the main UI path. This chapter follows what crosses those boundaries and then connects the ownership model to restart logic, manual targeting, the CLI, and the optional WebRTC path. The practical judgment is whether a design choice preserves the same identity and recovery obligations when the platform changes.
Policy crosses the boundary; protocol state stays consistent
A transfer app has two kinds of work that should not be confused. One kind is contextual: show a device, ask the user whether to accept files, choose a directory, honor a favorite, or adapt a save operation to Android or iOS. The other kind is mechanical: open a socket, perform a TLS handshake, validate a session and token, stream bytes, calculate a checksum, and close or cancel the connection correctly. LocalSend puts those responsibilities in different layers while connecting them through typed tasks and events.
The Flutter application is the policy and presentation layer. It owns settings such as the alias, port, HTTPS choice, receive PIN, quick-save behavior, and checksum preferences. It keeps nearby devices and transfer state for the UI, and it routes incoming server events to receive and send controllers. That is where a user-facing decision belongs: the core can report that a sender offered files, but it should not decide that a person always accepts them simply because the network request is valid.
The networking work runs through a Dart isolate and a Flutter-Rust-Bridge layer rather than on the main Flutter isolate. The isolate layer translates application actions into networking tasks and converts Rust results back into events. This is useful for more than keeping animations smooth. A multicast receive loop, a subnet scan, or a streaming upload has a different lifetime from a screen. If a screen rebuilds, a user changes pages, or a mobile platform pauses the UI, the network operation should not be represented as a pile of UI callbacks that must all remain alive.
The Rust core then provides one implementation of the protocol machinery. It contains the HTTP client and server, certificate handling, multicast discovery, transfer state, checksum behavior, and optional WebRTC support. The same core is consumed by the Flutter integration and by the Rust CLI. That reuse does not mean that every consumer has the same policy. It means that the low-level rules—what a valid token is, when a certificate is pinned, or how a session is cleaned up—do not need to be reimplemented separately in Dart and Rust for every platform.
The server boundary shows how this works in practice. When a sender prepares an upload, the Rust server emits an event containing the sender information, offered files, session context, and a decision channel. The Flutter application answers that channel. When an accepted file begins arriving, the server emits another event and waits for the application to provide a save target. The Rust side can then stream the body into a path, stream, or platform-specific destination while retaining ownership of request validation and transfer cleanup.
This is a deliberate direction of control.