From “Send” to Session: How LocalSend Negotiates and Streams Files
LocalSend's transfer protocol starts before a file starts moving. Once discovery has confirmed a reachable peer, the sender offers metadata and the receiver decides what, if anything, to accept. This chapter follows that transition from a request into a stateful upload session, then shows how per-file tokens, checksums, parallelism, cancellation, and cleanup constrain the data path. The result is a practical model for understanding what a successful “send” has authorized—and what it has not.
The request becomes an authorized session
The first step is deliberately non-binary. The sender posts a description of the proposed transfer to prepare-upload: file identifiers, names, sizes, types, and optional previews, timestamps, and SHA-256 checksums. The receiver does not need to accept the entire selection as one unit. The application can reject the request, accept every offered file, or accept only a subset.
That order serves both control and usability. A receiver may want to inspect a list before allowing a large transfer, decline an unexpected request, or accept a few documents while leaving unrelated files untouched. A preview or filename can be enough to make a decision; the receiver does not have to wait for the data phase to begin. In a system without a central account or policy service, this application-owned decision is where the user's intent enters the protocol.
The Rust core intentionally does not decide that every request should be accepted. It emits a preparation event to the Flutter application and waits for a decision. The application can apply its normal interactive policy, a PIN requirement, or a quick-save setting such as automatic acceptance for a favorite. The transport core supplies the state machine and enforcement points; the product layer supplies the context in which accepting a peer makes sense.
A PIN is therefore a gate on the preparation request, not a substitute for device identity. When a receiver requires one, the sender includes it as a query parameter. The current implementation counts failed attempts per IP and returns a too-many-requests response after three failures. A correct PIN permits the preparation step to continue, but it does not turn an unencrypted HTTP connection into an encrypted one or prove that the remote device is the one the user intended to select. Those are separate questions handled by transport identity and the user interface.
If the application accepts at least one file, the server creates a session identifier and issues a distinct token for every accepted file. The session ID identifies the transfer as a whole. The file token identifies the authorization for one particular file within that transfer. This separation matters when a selection is partial: a file the receiver declined never receives an upload token, so the sender has no valid data-phase authorization for it.
The preparation response also gives the sender a precise account of what happened. A successful response contains the session ID and only the accepted file tokens. A rejection is a different outcome from an empty accepted set, and an empty set can complete with no file body required. A PIN failure, an occupied session, and a receiver rejection are likewise different conditions. These distinctions give the application enough information to explain whether the problem was permission, authentication policy, current receiver state, or the absence of anything left to transfer.
The server keeps a single active v2 upload session slot. That does not mean the protocol can transfer only one file at a time. It means the receiver does not allow unrelated senders to claim competing sessions simultaneously through this server state. Within the accepted session, each file can be uploaded through its own request, and the client can run those requests in parallel. One controlled session can therefore use concurrency without turning the receiver into an unbounded collection of competing writes.
The session slot is also a statement about ownership. The receiver's application has one current decision context: one sender, one set of offered metadata, and one collection of file states. If two senders arrived together, their requests could otherwise compete for the same approval surface and storage destination. Blocking the second session is less spectacular than maximizing concurrency, but it gives the UI a coherent question to ask and gives the Rust server a single state object to validate as files arrive.
The difference is easier to see in a small example. Suppose a sender offers three files and the receiver accepts two. The server returns one session ID and two file tokens. The sender may upload the two accepted files concurrently, but an attempt to upload the declined file has no token that the receiver issued. If another sender tries to prepare a new session before the first session ends, the current v2 server reports that the session slot is occupied. Parallelism exists inside a decision; it does not bypass the decision.
The lifecycle can be represented as a set of transitions rather than as a single request/response pair:
The repeated FilePending → InProgress path is the per-file part of one active session. The receiver can move one file to a terminal state while another is still being streamed. Once every accepted file is either finished or failed, the session ends. That is a more useful mental model than imagining one monolithic “send” operation, because it explains partial acceptance, parallel requests, retries, and the point at which a new session becomes possible.
The data phase is guarded, verified, and cancellable
When an upload request arrives, the receiver does not trust the query string merely because the sender previously completed preparation. It checks the session ID, the sender's IP address, the file ID, the file's current status, and the token associated with that file. A mismatch returns a forbidden result rather than allowing the request to choose an arbitrary destination. The file must also be in a pending state; a second request cannot simply replay a token while the first attempt is still in progress.
Only after those checks does the server hand the body to the application's save target. The target might be a path, a stream, or a platform-specific destination such as an Android file descriptor. The Rust server owns the request and byte-handling path, while the application decides where the data should ultimately go. This division keeps platform storage differences out of the wire protocol without removing the application from the decision about the received file.
The bytes can be streamed instead of accumulated in memory. That matters for a cross-platform app that may run on a phone with limited resources, and it explains why the transfer lifecycle needs explicit progress and failure states. A successful preparation response says that a transfer is allowed to start; it does not mean that the file has been written successfully. The final result still depends on the request body, the save target, and any integrity check the receiver has enabled.
The wire-level file ID is deliberately not the same concept as the display filename. The sender can describe a file as “presentation.pdf” for the user while the protocol uses an internal identifier to associate the upload body with the metadata and token that were accepted. That association prevents the data request from selecting a name and destination on its own. The receiver's application can then choose a platform-appropriate save target while the core continues to enforce the same session and file authorization rules.
This is also why progress and completion cannot be inferred from the preparation response. Preparation transfers metadata and creates permission to attempt a body; the file-upload event occurs later, and the application must provide a target that can consume the stream or write it. A file can be accepted, start uploading, fail while being saved, and leave the session in a mixed state with other files still pending or finished. The lifecycle is doing real coordination work even though the public interface can remain a simple sequence of HTTP requests.
A checksum addresses a specific failure mode: the bytes received are not the bytes the sender declared. If the sender provides a SHA-256 value and verification is enabled, the receiver can compare the calculated result and return a checksum-mismatch response when they differ. The current implementation can reset a mismatched file to pending for a bounded retry period, allowing the sender to try again with the same session and file authorization. After the retry limit is exhausted, the file becomes failed and the session can finish with that file in a terminal state.
A checksum does not answer other questions. It does not identify the remote device, authorize a request, or establish confidentiality. An attacker or malfunctioning sender can provide a checksum for the wrong content; the receiver will detect only whether the received bytes match the supplied value. Authenticity comes from the transport and peer-identity model, while consent comes from the preparation decision. Keeping those meanings separate prevents a successful integrity check from being treated as proof that the transfer was safe in every other sense.
The application has separate choices for creating checksums when sending and verifying them when receiving. That separation is practical: calculating a digest consumes time and I/O, while verification is a receiver-side policy about how strongly to validate incoming bytes. The source provides correctness tests for matching and mismatching values, disabled verification, retries, and exhausted attempts, but it does not provide a general performance number for checksum work on large files or slower devices.
Cancellation is another state transition, not just a button that closes a screen. A sender can cancel an active session through the cancel route. If the receiver owns that session, it clears the active state and notifies the application that the session ended. If the application was still deciding whether to accept the request, the sender may disconnect or cancel before a session response exists; the server must then release the pending slot so that the next request is not mistaken for a conflicting session.
The implementation uses drop-safe guards for exactly this reason. A pending-session guard clears a claimed slot if the request disappears before the application answers and emits an aborted event. An upload guard marks an in-progress file as failed if the body or save operation is abandoned. Without those cleanup paths, a temporary network failure could leave a receiver believing that a session or file was still active indefinitely. The next legitimate sender would see a conflict even though no user-visible transfer remained.
The current single-session rule also reveals a deliberate trade-off. It simplifies receiver state, prevents unrelated sessions from competing for storage decisions, and gives the application a clear unit for accept/decline UI. The cost is that independent senders cannot all transfer through the same v2 server at once. LocalSend chooses controlled ownership over unlimited concurrency; parallelism is available where it is most useful—among files already accepted within one session.
This lifecycle explains why discovery alone is not permission. Discovery confirms that a route and peer identity are available. Preparation creates a human- or policy-readable offer. Tokens narrow the data phase to the accepted files. Checksums test byte integrity, and cleanup makes failure recoverable. The next security question is how much confidence the transport identity supplies before the metadata offer and file body cross that confirmed channel.