feat: implement CLI
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-07-29 00:37:00 +02:00
parent 275b399e7c
commit 0aea05ac85
47 changed files with 8227 additions and 672 deletions
+9 -2
View File
@@ -77,9 +77,16 @@ jobs:
echo "Inno Setup version is $VERSION"
echo "inno_version=$VERSION" >> $GITHUB_ENV
- name: Compare pubspec and Inno Setup versions
- name: Extract version from CLI Cargo.toml
id: cli_version
run: |
if [ "$pubspec_version" != "$inno_version" ]; then
VERSION=$(grep '^version = ' cli/Cargo.toml | sed 's/version = "//' | sed 's/"//')
echo "CLI version is $VERSION"
echo "cli_version=$VERSION" >> $GITHUB_ENV
- name: Compare pubspec, Inno Setup and CLI versions
run: |
if [ "$pubspec_version" != "$inno_version" ] || [ "$pubspec_version" != "$cli_version" ]; then
echo "Version mismatch detected!"
exit 1
else
+2 -2
View File
@@ -19,7 +19,7 @@ This is a multi-language monorepo: a Flutter app on top of a Rust protocol imple
| `packages/core/` | Rust crate `localsend`: protocol, HTTP server/client, crypto, WebRTC. No Flutter dependency. |
| `packages/typed_isolates/` | Small standalone package wrapping Dart `Isolate` with typed send/receive channels. |
| `server/` | Axum WebSocket signaling server for WebRTC (`/v1/ws`). Deployed separately, see `server/Dockerfile`. |
| `cli/` | Rust CLI crate — currently a stub. |
| `cli/` | Rust CLI crate (`localsend-cli`): interactive terminal client on top of `packages/core` (v2 HTTP + multicast). |
| `support/scripts/` | Release/packaging scripts (per-platform builds, MSIX, Inno Setup, FOSS stripping). |
There is no Cargo workspace; `packages/core`, `packages/localsend_isolates/rust`, `server`, and `cli` are independent crates.
@@ -133,4 +133,4 @@ Slang, source files in `app/assets/i18n/` (`<locale>.json` plus `_missing_transl
## Release notes
`app/pubspec.yaml`'s version must match `#define MyAppVersion` in `support/scripts/compile_windows_exe-inno.iss` — CI fails on a mismatch. Platform build commands and release steps are documented in `README.md` ("Building") and `CONTRIBUTING.md` ("Release").
`app/pubspec.yaml`'s version must match `#define MyAppVersion` in `support/scripts/compile_windows_exe-inno.iss` and the `version` in `cli/Cargo.toml` (the CLI prints it in its start banner) — CI fails on a mismatch. Platform build commands and release steps are documented in `README.md` ("Building") and `CONTRIBUTING.md` ("Release").
-11
View File
@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>LocalSend</title>
</head>
<body>
<h1>403 Forbidden</h1>
<p>You don't have permission to access this resource.</p>
</body>
</html>
-98
View File
@@ -1,98 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalSend</title>
<style>
body {
font-family: sans-serif, system-ui;
margin: 0;
line-height: 1.5;
text-align: center;
}
a {
text-decoration: none;
color: #FFFFFF;
}
#file-list {
box-sizing: border-box;
width: 100%;
max-width: 1000px;
padding: 0 1em;
margin: 0 auto;
text-align: left;
}
.file-item {
display: table;
width: 100%;
padding: 0.5em 1em;
background-color: #00796B;
border-radius: 0.5em;
margin-bottom: 1em;
float: left;
box-sizing: border-box;
}
.file-item:hover {
background-color: #009688;
}
.file-name-cell, .file-size-cell, .file-index-cell {
display: table-cell;
vertical-align: middle;
}
.file-name-cell {
width: 100%;
padding: 0 0.5em;
}
.file-size-cell, .file-index-cell {
font-size: 0.8em;
white-space: nowrap;
color: #E0E0E0;
}
/* Two columns layout */
.file-item {
width: 100%;
}
@media screen and (min-width: 768px) {
#file-list.file-item {
width: 48%;
margin-right: 2%;
}
#file-list.file-item:nth-child(2n) {
margin-right: 0;
}
}
/* Single file mode */
#single-file {
box-sizing: border-box;
width: 100%;
text-align: left;
max-width: 400px;
padding: 0 1em;
margin: 0 auto;
}
</style>
<script src="main.js" defer></script>
</head>
<body>
<noscript>
LocalSend requires JavaScript, which is currently disabled. Please enable it and try again.
</noscript>
<h1 style="padding: 0.5em">LocalSend</h1>
<p id="status-text"></p>
<div id="file-list"></div>
<div id="single-file"></div>
</body>
</html>
-178
View File
@@ -1,178 +0,0 @@
// IMPORTANT: This script works in Internet Explorer 8!
var BASE_URL = '/api/localsend/v2';
var i18n = {};
var sessionId = sessionStorage.getItem('sessionId');
var queryParams = location.search.slice(1).split('&');
var queryPin = null;
// Parse query parameters manually for IE
for (var i = 0; i < queryParams.length; i++) {
var pair = queryParams[i].split('=');
if (pair[0] === 'pin') {
queryPin = decodeURIComponent(pair[1]);
break;
}
}
function firstRequestFiles() {
document.getElementById('status-text').innerText = i18n.waiting;
var initialUrl = BASE_URL + '/prepare-download';
if (sessionId) {
initialUrl += '?sessionId=' + encodeURIComponent(sessionId);
if (queryPin) {
initialUrl += '&pin=' + encodeURIComponent(queryPin);
}
} else if (queryPin) {
initialUrl += '?pin=' + encodeURIComponent(queryPin);
}
makeRequest(initialUrl, 'POST', function (response) {
if (response.status === 401) {
pinRequestFiles(true);
return;
}
if (response.status === 403) {
document.getElementById('status-text').innerText = i18n.rejected;
return;
}
if (response.status === 429) {
document.getElementById('status-text').innerText = i18n.tooManyAttempts;
return;
}
if (response.status !== 200) {
document.getElementById('status-text').innerText = 'Error: ' + response.status;
return;
}
handleSuccess(response);
});
}
function pinRequestFiles(firstAttempt) {
var pin = prompt(i18n.enterPin + (firstAttempt ? '' : '\n' + i18n.invalidPin));
if (!pin) {
document.getElementById('status-text').innerText = i18n.invalidPin;
return;
}
makeRequest(BASE_URL + '/prepare-download?pin=' + encodeURIComponent(pin), 'POST', function (response) {
if (response.status === 401) {
pinRequestFiles(false);
return;
}
if (response.status === 403) {
document.getElementById('status-text').innerText = i18n.rejected;
return;
}
if (response.status === 429) {
document.getElementById('status-text').innerText = i18n.tooManyAttempts;
return;
}
if (response.status !== 200) {
document.getElementById('status-text').innerText = 'Error: ' + response.status;
return;
}
handleSuccess(response);
});
}
function makeRequest(url, method, callback) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
callback(xhr);
}
};
xhr.send();
}
function fetchI18n(then) {
makeRequest('/i18n.json', 'GET', function (response) {
if (response.status === 200) {
i18n = JSON.parse(response.responseText);
then();
}
});
}
function init() {
fetchI18n(firstRequestFiles);
}
function handleSuccess(response) {
var data = JSON.parse(response.responseText);
var files = data.files;
sessionId = data.sessionId;
sessionStorage.setItem('sessionId', sessionId);
document.getElementById('status-text').innerText = i18n.files + ' (' + getKeys(data.files).length + ')';
// Handling file display
handleFilesDisplay(files, sessionId);
}
function handleFilesDisplay(files, sessionId) {
var html= '';
var fileKeys = getKeys(files);
for (var i = 0; i < fileKeys.length; i++) {
var file = files[fileKeys[i]];
html += '<a class="file-item" href="' + BASE_URL + '/download?sessionId=' + encodeURIComponent(sessionId) + '&fileId=' + encodeURIComponent(fileKeys[i]) + '">' +
'<div class="file-index-cell">' + (i + 1) + '</div>' +
'<div class="file-name-cell">' + escapeHtml(file.fileName) + '</div>' +
'<div class="file-size-cell">' + formatBytes(file.size) + '</div>' +
'</a>';
}
if (fileKeys.length === 1) {
document.getElementById('single-file').innerHTML = html;
} else {
document.getElementById('file-list').innerHTML = html;
}
}
function escapeHtml(text) {
if (text === null || text === undefined) {
return '';
}
var map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return String(text).replace(/[&<>"']/g, function(m) { return map[m]; });
}
function formatBytes(bytes) {
if (bytes < 1024) {
return bytes + ' B';
} else if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
} else if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
} else {
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
}
}
function getKeys(obj) {
var keys = [];
for (var key in obj) {
keys.push(key);
}
return keys;
}
init();
-17
View File
@@ -42,28 +42,11 @@ class $AssetsImgGen {
List<dynamic> get values => [logo128, logo256, logo32Black, logo32White, logo32, logo512White, logo512, logo];
}
class $AssetsWebGen {
const $AssetsWebGen();
/// File path: assets/web/error-403.html
String get error403 => 'assets/web/error-403.html';
/// File path: assets/web/index.html
String get index => 'assets/web/index.html';
/// File path: assets/web/main.js
String get main => 'assets/web/main.js';
/// List of all assets
List<String> get values => [error403, index, main];
}
class Assets {
const Assets._();
static const String changelog = 'assets/CHANGELOG.md';
static const $AssetsImgGen img = $AssetsImgGen();
static const $AssetsWebGen web = $AssetsWebGen();
/// List of all assets
static List<String> get values => [changelog];
+94 -75
View File
@@ -51,6 +51,12 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
/// Session ID -> Cancel token
final _hashCancelTokens = <String, rust_cancel.RsCancellationToken>{};
/// Cancel tokens of the running prepare-upload requests.
/// Cancelling aborts the request, which tells the receiver that the sender
/// is no longer waiting for a decision.
/// Session ID -> Cancel token
final _prepareUploadCancelTokens = <String, rust_cancel.RsCancellationToken>{};
@override
Map<String, SendSessionState> init() {
return {};
@@ -195,92 +201,99 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
bool invalidPin;
bool pinFirstAttempt = true;
String? pin;
do {
invalidPin = false;
try {
response = await client.prepareUpload(
protocol: target.getProtocolType(),
ip: target.ip!,
port: target.port,
payload: requestDto,
// The peer is already verified during the TLS handshake by the
// fingerprint the client is pinned to.
publicKey: null,
pin: pin,
);
} on rust_http.RsHttpClientError_StatusCode catch (e) {
switch (e.status) {
case 401:
invalidPin = true;
final prepareUploadCancelToken = rust_cancel.createCancellationToken();
_prepareUploadCancelTokens[sessionId] = prepareUploadCancelToken;
try {
do {
invalidPin = false;
try {
response = await client.prepareUpload(
protocol: target.getProtocolType(),
ip: target.ip!,
port: target.port,
payload: requestDto,
// The peer is already verified during the TLS handshake by the
// fingerprint the client is pinned to.
publicKey: null,
pin: pin,
cancelToken: prepareUploadCancelToken,
);
} on rust_http.RsHttpClientError_StatusCode catch (e) {
switch (e.status) {
case 401:
invalidPin = true;
// wait until animation is finished
await sleepAsync(500);
// wait until animation is finished
await sleepAsync(500);
pin = await showDialog<String>(
context: Routerino.context, // ignore: use_build_context_synchronously
builder: (_) => PinDialog(
obscureText: true,
showInvalidPin: !pinFirstAttempt,
),
);
pin = await showDialog<String>(
context: Routerino.context, // ignore: use_build_context_synchronously
builder: (_) => PinDialog(
obscureText: true,
showInvalidPin: !pinFirstAttempt,
),
);
pinFirstAttempt = false;
pinFirstAttempt = false;
if (pin == null) {
if (pin == null) {
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.canceledBySender,
),
);
return;
}
break;
case 403:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.canceledBySender,
status: SessionStatus.declined,
),
);
return;
}
break;
case 403:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.declined,
),
);
return;
case 409:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.recipientBusy,
),
);
return;
case 429:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.tooManyAttempts,
),
);
return;
default:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.finishedWithErrors,
errorMessage: e.humanErrorMessage,
),
);
return;
case 409:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.recipientBusy,
),
);
return;
case 429:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.tooManyAttempts,
),
);
return;
default:
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.finishedWithErrors,
errorMessage: e.humanErrorMessage,
),
);
return;
}
} catch (e) {
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.finishedWithErrors,
errorMessage: e.humanErrorMessage,
),
);
return;
}
} catch (e) {
state = state.updateSession(
sessionId: sessionId,
state: (s) => s?.copyWith(
status: SessionStatus.finishedWithErrors,
errorMessage: e.humanErrorMessage,
),
);
return;
}
} while (invalidPin);
} while (invalidPin);
} finally {
_prepareUploadCancelTokens.remove(sessionId);
}
if (response == null) {
return;
@@ -672,6 +685,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
void _cancelRunningRequests(SendSessionState state) {
_hashCancelTokens.remove(state.sessionId)?.cancel();
_prepareUploadCancelTokens.remove(state.sessionId)?.cancel();
for (final task in state.sendingTasks ?? <SendingTask>[]) {
ref
@@ -692,6 +706,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
}
TransferNotification.stop(sessionId);
_hashCancelTokens.remove(sessionId)?.cancel();
_prepareUploadCancelTokens.remove(sessionId)?.cancel();
state = state.removeSession(ref, sessionId);
if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) {
// clear selected files
@@ -707,6 +722,10 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
cancelToken.cancel();
}
_hashCancelTokens.clear();
for (final cancelToken in _prepareUploadCancelTokens.values) {
cancelToken.cancel();
}
_prepareUploadCancelTokens.clear();
state = {};
ref.notifier(progressProvider).removeAllSessions();
}
@@ -223,8 +223,8 @@ class ReceiveController {
final receiveState = server.getStateOrNull()?.session;
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) {
_logger.warning('Rejecting upload of file ${event.fileId}: no matching active session');
// Reject the upload (and any further ones) by cancelling the session on the Rust side.
_logger.warning('Failing upload of file ${event.fileId}: no matching active session');
// Fail the upload (and any further ones) by cancelling the session on the Rust side.
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId));
return;
}
@@ -628,7 +628,7 @@ class ReceiveController {
}
/// In addition to [closeSession], this method also
/// - cancels the session on the Rust server so that further uploads are rejected
/// - cancels the session on the Rust server so that further uploads fail
/// - notifies the sender that the session has been canceled
void cancelSession() async {
final session = server.getStateOrNull()?.session;
@@ -637,7 +637,7 @@ class ReceiveController {
return;
}
// reject further uploads
// fail further uploads
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: session.sessionId));
// notify sender
@@ -138,7 +138,7 @@ class SendController {
} catch (e, st) {
_logger.severe('Failed to resolve source for web send file ${event.fileId}', e, st);
// Unblock the web client's request waiting for the content source.
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerRejectFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId));
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerFailFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId));
return;
}
-1
View File
@@ -110,7 +110,6 @@ flutter:
assets:
- assets/img/
- assets/web/
- assets/CHANGELOG.md
flutter_gen:
+2 -2
View File
@@ -7,13 +7,13 @@ import 'dart:async' as _i4;
import 'package:flutter/material.dart' as _i8;
import 'package:localsend_app/gen/strings.g.dart' as _i10;
import 'package:localsend_isolates/model/device.dart' as _i12;
import 'package:localsend_isolates/model/stored_security_context.dart' as _i2;
import 'package:localsend_app/model/persistence/color_mode.dart' as _i9;
import 'package:localsend_app/model/persistence/favorite_device.dart' as _i6;
import 'package:localsend_app/model/persistence/receive_history_entry.dart' as _i5;
import 'package:localsend_app/model/send_mode.dart' as _i11;
import 'package:localsend_app/provider/persistence_provider.dart' as _i3;
import 'package:localsend_isolates/model/device.dart' as _i12;
import 'package:localsend_isolates/model/stored_security_context.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
import 'package:mockito/src/dummies.dart' as _i7;
import 'package:shared_preferences/shared_preferences.dart' as _i13;
+1
View File
@@ -0,0 +1 @@
target/
+4768
View File
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -1,6 +1,31 @@
[package]
name = "cli"
version = "0.1.0"
name = "localsend-cli"
version = "1.17.0"
edition = "2024"
[[bin]]
name = "localsend-cli"
path = "src/main.rs"
[dependencies]
localsend = { path = "../packages/core", features = ["full"] }
anyhow = "1.0"
bytes = "1.11"
clap = { version = "4.6", features = ["derive", "env"] }
crossterm = "0.29"
dirs = "6.0"
futures-util = "0.3"
gethostname = "1.0"
if-addrs = "0.15"
mime_guess = "2.0"
pem = "4.0"
ratatui = "0.30"
ratatui-explorer = "0.3"
rcgen = "0.14"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = { version = "0.7", features = ["rt"] }
toml = "1.1"
uuid = { version = "1", features = ["v4"] }
+91
View File
@@ -0,0 +1,91 @@
//! Device discovery: multicast announcements are answered with an HTTP
//! register request, and confirmed devices get a slot in the registry.
use super::{App, AppEvent};
use crate::ui::Category;
use localsend::http::client::v2::LsHttpClientV2;
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::ProtocolTypeV2;
use localsend::multicast::MulticastEvent;
use std::time::Duration;
impl App {
pub(super) fn handle_multicast(&mut self, event: MulticastEvent) {
let MulticastEvent::Discovered {
ip,
scope_id,
message,
} = event;
if message.fingerprint == self.storage.identity.fingerprint {
return;
}
let host = match scope_id {
Some(scope_id) => format!("{ip}%{scope_id}"),
None => ip.to_string(),
};
// Answer the announcement with an HTTP register request; the device
// is only shown once that request succeeds.
let identity = self.storage.identity.clone();
let events_tx = self.events_tx.clone();
tokio::spawn(async move {
let expected_fingerprint = match message.protocol {
ProtocolTypeV2::Https => Some(message.fingerprint.clone()),
ProtocolTypeV2::Http => None,
};
let Ok(client) = LsHttpClientV2::try_new(
&identity.key_pem,
&identity.cert_pem,
expected_fingerprint,
Some(Duration::from_secs(5)),
) else {
return;
};
let protocol = match message.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
};
let result = client
.register(protocol, &host, message.port, identity.register_dto())
.await;
if let Ok(response) = result {
let _ = events_tx
.send(AppEvent::DeviceUp {
alias: response.body.alias,
host,
port: message.port,
protocol: message.protocol,
fingerprint: message.fingerprint,
})
.await;
}
});
}
pub(super) fn device_up(
&mut self,
alias: String,
host: String,
port: u16,
protocol: ProtocolTypeV2,
fingerprint: String,
) {
if fingerprint == self.storage.identity.fingerprint {
return;
}
if let Some(device) = self
.registry
.upsert(alias, host, port, protocol, fingerprint)
{
self.ui.log(
Category::Discovery,
&format!(
"[{}] {} ({})",
device.slot_label(),
device.alias,
device.host
),
);
}
}
}
+294
View File
@@ -0,0 +1,294 @@
mod discovery;
mod receive;
mod sending;
mod status;
use crate::Args;
use crate::devices::DeviceRegistry;
use crate::picker::Picker;
use crate::storage;
use crate::ui::{Category, Ui};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use localsend::http::dto_v2::ProtocolTypeV2;
use localsend::http::server::v2::ServerEventV2;
use localsend::http::server::{ServerConfigV2, ServerHandle, start_with_port};
use localsend::multicast::{
self, DEFAULT_MULTICAST_GROUP, DEFAULT_MULTICAST_GROUP_V6, DEFAULT_PORT, InterfaceFilter,
MulticastConfig, MulticastEvent,
};
use receive::{Answer, PendingReceive, ReceiveSession};
use sending::SendState;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
/// Events processed by the central application loop.
pub enum AppEvent {
/// A key was pressed.
Key(KeyEvent),
/// A device was confirmed reachable (it registered with us, or it
/// announced itself and answered our register request).
DeviceUp {
alias: String,
host: String,
port: u16,
protocol: ProtocolTypeV2,
fingerprint: String,
},
/// A file of the active receive session finished (or failed).
ReceiveFileResult {
session_id: String,
file_id: String,
result: Result<(), String>,
},
/// The send task created its upload session.
SendSessionStarted {
session_id: String,
accepted_bytes: u64,
},
/// The send task ended (successfully or not).
SendEnded,
/// A log line produced by a background task.
Log { category: Category, text: String },
}
struct App {
ui: Ui,
server: Arc<ServerHandle>,
registry: DeviceRegistry,
/// Config, identity and paired devices, see [`storage::Repository`].
storage: storage::Repository,
pending: Option<PendingReceive>,
receive: Option<ReceiveSession>,
send: Option<SendState>,
picker: Option<Picker>,
events_tx: mpsc::Sender<AppEvent>,
}
pub async fn run(args: Args) -> anyhow::Result<()> {
let storage = storage::Repository::load(&args)?;
let identity = storage.identity.clone();
let (events_tx, mut events_rx) = mpsc::channel::<AppEvent>(64);
// HTTP server (always TLS, like the app).
let (server_tx, mut server_rx) = mpsc::channel::<ServerEventV2>(16);
let (server_stop_tx, server_stop_rx) = oneshot::channel::<()>();
let server = start_with_port(
identity.port,
Some(identity.tls_config()),
identity.client_info(),
None,
Some(ServerConfigV2 {
pin: None,
event_tx: server_tx,
}),
None,
server_stop_rx,
)
.await?;
let server = Arc::new(server);
// Multicast discovery. Failure is not fatal: transfers to this device
// still work for peers that know its address.
let (multicast_tx, multicast_rx) = mpsc::channel::<MulticastEvent>(16);
let (multicast_stop_tx, multicast_stop_rx) = oneshot::channel::<()>();
let multicast = multicast::start(
MulticastConfig {
group: DEFAULT_MULTICAST_GROUP,
group_v6: Some(DEFAULT_MULTICAST_GROUP_V6),
port: DEFAULT_PORT,
interface_filter: InterfaceFilter::default(),
device: identity.multicast_device(),
event_tx: multicast_tx,
},
multicast_stop_rx,
)
.await;
let multicast = match multicast {
Ok(handle) => Some(Arc::new(handle)),
Err(err) => {
eprintln!("Multicast discovery unavailable: {err:#}");
None
}
};
let mut multicast_rx = multicast.is_some().then_some(multicast_rx);
if let Some(handle) = &multicast {
// Announce this device; peers answer with an HTTP register request.
let handle = handle.clone();
tokio::spawn(async move { handle.announce().await });
}
crossterm::terminal::enable_raw_mode()?;
// Keyboard reader. The blocking thread ends with the process.
std::thread::spawn({
let events_tx = events_tx.clone();
move || {
loop {
match crossterm::event::read() {
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => {
if events_tx.blocking_send(AppEvent::Key(key)).is_err() {
return;
}
}
Ok(_) => {}
Err(_) => return,
}
}
}
});
let mut app = App {
ui: Ui::new(),
server: server.clone(),
registry: DeviceRegistry::new(),
storage,
pending: None,
receive: None,
send: None,
picker: None,
events_tx: events_tx.clone(),
};
app.ui.log_plain(&crate::banner::render(&app.storage));
let mut tick = tokio::time::interval(Duration::from_millis(250));
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut quit = false;
while !quit {
tokio::select! {
Some(event) = events_rx.recv() => {
quit = app.handle_event(event).await;
}
Some(event) = server_rx.recv() => {
app.handle_server_event(event);
}
Some(event) = recv_opt(&mut multicast_rx) => {
app.handle_multicast(event);
}
_ = tick.tick() => {
app.tick();
}
}
}
// Shutdown: leave a possibly open picker, restore the terminal, stop the
// network tasks (briefly, so the ports are released cleanly).
if let Some(picker) = app.picker.take() {
picker.close();
app.ui.resume();
}
app.ui.set_status(None);
let _ = crossterm::terminal::disable_raw_mode();
let _ = server_stop_tx.send(());
let _ = multicast_stop_tx.send(());
let _ = tokio::time::timeout(Duration::from_secs(1), server.wait_stopped()).await;
if let Some(multicast) = &multicast {
let _ = tokio::time::timeout(Duration::from_secs(1), multicast.wait_stopped()).await;
}
println!("Bye!");
Ok(())
}
/// Receives from an optional channel, pending forever when there is none.
async fn recv_opt<T>(rx: &mut Option<mpsc::Receiver<T>>) -> Option<T> {
match rx {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
}
impl App {
/// Handles an event; returns `true` when the application should quit.
async fn handle_event(&mut self, event: AppEvent) -> bool {
match event {
AppEvent::Key(key) => return self.handle_key(key),
AppEvent::DeviceUp {
alias,
host,
port,
protocol,
fingerprint,
} => self.device_up(alias, host, port, protocol, fingerprint),
AppEvent::ReceiveFileResult {
session_id,
file_id,
result,
} => self.receive_file_result(session_id, file_id, result),
AppEvent::SendSessionStarted {
session_id,
accepted_bytes,
} => {
if let Some(send) = &mut self.send {
send.session_id = Some(session_id);
send.total_bytes = accepted_bytes;
}
}
AppEvent::SendEnded => {
self.send = None;
self.render_status();
}
AppEvent::Log { category, text } => self.ui.log(category, &text),
}
false
}
fn handle_key(&mut self, key: KeyEvent) -> bool {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return self.handle_ctrl_c();
}
// While the picker is open it consumes every key.
if self.picker.is_some() {
self.handle_picker_key(key);
return false;
}
if let KeyCode::Char(c) = key.code {
match c.to_ascii_lowercase() {
'y' => self.answer_pending(Answer::Accept),
'n' => self.answer_pending(Answer::Decline),
'p' => self.answer_pending(Answer::AcceptAndPair),
'1'..='9' => self.start_picking(c as u8 - b'0'),
_ => {}
}
}
false
}
/// Cancels the current activity: the picker, the pending request and the
/// active transfers. Returns `true` (quit) only when there was nothing to
/// cancel.
fn handle_ctrl_c(&mut self) -> bool {
if let Some(picker) = self.picker.take() {
picker.close();
self.ui.resume();
return false;
}
let mut cancelled = false;
if self.pending.is_some() {
self.answer_pending(Answer::Decline);
cancelled = true;
}
if let Some(send) = &self.send {
// The send task notices the token, notifies the receiver and
// reports back via [AppEvent::SendEnded].
send.cancel.token.cancel();
cancelled = true;
}
if self.receive.is_some() {
self.cancel_receive();
cancelled = true;
}
!cancelled
}
}
+452
View File
@@ -0,0 +1,452 @@
//! The receiving side: incoming transfer requests, the Y/N/P decision and the
//! active receive session.
use super::App;
use crate::ui::Category;
use crate::util::{self, SpeedMeter};
use localsend::http::client::v2::LsHttpClientV2;
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::ProtocolTypeV2;
use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2};
use localsend::model::transfer::FileDto;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, oneshot};
/// Where to reach the device a receive session originates from, so a
/// receiver-side cancel can be delivered back to it over HTTP.
#[derive(Clone)]
pub(super) struct SenderTarget {
pub(super) host: String,
pub(super) port: u16,
pub(super) protocol: ProtocolTypeV2,
pub(super) fingerprint: String,
}
/// An incoming transfer request waiting for the user's Y/N/P decision.
pub(super) struct PendingReceive {
pub(super) session_id: String,
pub(super) alias: String,
pub(super) sender: SenderTarget,
pub(super) files: HashMap<String, FileDto>,
pub(super) decision_tx: oneshot::Sender<PrepareUploadDecisionV2>,
}
/// An accepted upload session that is being received.
pub(super) struct ReceiveSession {
pub(super) session_id: String,
pub(super) alias: String,
pub(super) sender: SenderTarget,
pub(super) files: HashMap<String, FileDto>,
pub(super) total_bytes: u64,
pub(super) finished_files: usize,
pub(super) failed_files: usize,
pub(super) finalized_bytes: u64,
pub(super) in_progress: HashMap<String, Arc<AtomicU64>>,
pub(super) started: Instant,
pub(super) speed: SpeedMeter,
/// Set when the server reported the end of the session. The summary is
/// deferred until the in-flight per-file results have all arrived.
pub(super) ended: Option<SessionEndReasonV2>,
}
impl ReceiveSession {
fn new(
session_id: String,
alias: String,
sender: SenderTarget,
files: HashMap<String, FileDto>,
) -> Self {
let total_bytes = files.values().map(|file| file.size).sum();
Self {
session_id,
alias,
sender,
files,
total_bytes,
finished_files: 0,
failed_files: 0,
finalized_bytes: 0,
in_progress: HashMap::new(),
started: Instant::now(),
speed: SpeedMeter::new(),
ended: None,
}
}
pub(super) fn done_bytes(&self) -> u64 {
self.finalized_bytes
+ self
.in_progress
.values()
.map(|progress| progress.load(Ordering::Relaxed))
.sum::<u64>()
}
}
/// The user's decision on a [`PendingReceive`].
pub(super) enum Answer {
Accept,
Decline,
AcceptAndPair,
}
impl App {
pub(super) fn handle_server_event(&mut self, event: ServerEventV2) {
match event {
ServerEventV2::Register { ip, info } => {
self.device_up(
info.alias,
ip.to_string(),
info.port,
info.protocol,
info.fingerprint,
);
}
ServerEventV2::PrepareUpload {
session_id,
ip,
info,
cert_fingerprint,
files,
decision_tx,
} => {
// The sender is clearly reachable; make sure it has a slot.
self.device_up(
info.alias.clone(),
ip.to_string(),
info.port,
info.protocol,
info.fingerprint.clone(),
);
let sender = SenderTarget {
host: ip.to_string(),
port: info.port,
protocol: info.protocol,
fingerprint: cert_fingerprint.unwrap_or_else(|| info.fingerprint.clone()),
};
if self.storage.paired.contains(&sender.fingerprint) {
let ids: HashSet<String> = files.keys().cloned().collect();
if decision_tx
.send(PrepareUploadDecisionV2::Accept(ids))
.is_ok()
{
// Nothing to confirm: the progress bar and the summary
// are the whole story for an auto-accepted request.
self.receive =
Some(ReceiveSession::new(session_id, info.alias, sender, files));
}
} else {
let total: u64 = files.values().map(|file| file.size).sum();
let mut lines = vec![info.alias.clone(), "\nFiles:".to_string()];
let mut sorted: Vec<&FileDto> = files.values().collect();
sorted.sort_by_key(|file| &file.file_name);
for file in sorted {
lines.push(format!(
" {} ({})",
file.file_name,
util::format_bytes(file.size)
));
}
lines.push(format!("Total size: {}", util::format_bytes(total)));
lines.push("\nAccept? Y/N/P (P = accept and pair)".to_string());
self.ui.log(Category::Receive, &lines.join("\n"));
self.pending = Some(PendingReceive {
session_id,
alias: info.alias,
sender,
files,
decision_tx,
});
}
}
ServerEventV2::FileUpload {
session_id,
file_id,
file,
target_tx,
} => self.handle_file_upload(session_id, file_id, file, target_tx),
ServerEventV2::SessionEnd { session_id, reason } => {
let Some(session) = self
.receive
.as_mut()
.filter(|session| session.session_id == session_id)
else {
return;
};
// The per-file results race with this event (they arrive on
// the app's own channel); print the summary only once the
// last in-flight file has reported its outcome.
session.ended = Some(reason);
self.finish_receive_if_done();
}
ServerEventV2::PrepareUploadAborted { session_id } => {
if let Some(pending) = &self.pending
&& pending.session_id == session_id
{
let pending = self.pending.take().unwrap();
self.ui.log(
Category::Receive,
&format!("{}: Aborted by sender", pending.alias),
);
}
}
ServerEventV2::CancelReceived { ip, session_id } => {
if let Some(send) = &self.send
&& send.session_id.as_deref() == Some(session_id.as_str())
&& send.host == ip.to_string()
{
send.cancel.by_peer.store(true, Ordering::Relaxed);
send.cancel.token.cancel();
}
}
}
}
fn handle_file_upload(
&mut self,
session_id: String,
file_id: String,
file: FileDto,
target_tx: oneshot::Sender<FileUploadTarget>,
) {
let Some(session) = self
.receive
.as_mut()
.filter(|session| session.session_id == session_id)
else {
// Unknown session: dropping the responder fails the request.
return;
};
let path = util::unique_path(&self.storage.destination, &file.file_name);
let progress = Arc::new(AtomicU64::new(0));
session
.in_progress
.insert(file_id.clone(), progress.clone());
let (progress_tx, mut progress_rx) = mpsc::channel::<u64>(16);
tokio::spawn(async move {
while let Some(written) = progress_rx.recv().await {
progress.store(written, Ordering::Relaxed);
}
});
let (result_tx, result_rx) = oneshot::channel::<Result<(), String>>();
tokio::spawn({
let events_tx = self.events_tx.clone();
async move {
let result = match result_rx.await {
Ok(result) => result,
Err(_) => Err("Upload aborted".to_string()),
};
let _ = events_tx
.send(super::AppEvent::ReceiveFileResult {
session_id,
file_id,
result,
})
.await;
}
});
let _ = target_tx.send(FileUploadTarget::Path {
path,
result_tx,
progress_tx: Some(progress_tx),
});
}
pub(super) fn receive_file_result(
&mut self,
session_id: String,
file_id: String,
result: Result<(), String>,
) {
let Some(session) = self
.receive
.as_mut()
.filter(|session| session.session_id == session_id)
else {
return;
};
session.in_progress.remove(&file_id);
match result {
Ok(()) => {
session.finished_files += 1;
session.finalized_bytes += session
.files
.get(&file_id)
.map(|file| file.size)
.unwrap_or(0);
}
Err(err) => {
session.failed_files += 1;
let name = session
.files
.get(&file_id)
.map(|file| file.file_name.clone())
.unwrap_or(file_id);
let alias = session.alias.clone();
self.ui.log(
Category::Receive,
&format!("{alias}: failed to receive {name}: {err}"),
);
}
}
self.finish_receive_if_done();
}
/// Prints the session summary and clears the session once the server
/// reported its end and no per-file result is outstanding.
fn finish_receive_if_done(&mut self) {
let done = self
.receive
.as_ref()
.is_some_and(|session| session.ended.is_some() && session.in_progress.is_empty());
if !done {
return;
}
let session = self.receive.take().unwrap();
match session.ended.unwrap() {
SessionEndReasonV2::Finished => {
let mut text = format!(
"{}: Received {} file{} ({}, took {})",
session.alias,
session.finished_files,
if session.finished_files == 1 { "" } else { "s" },
util::format_bytes(session.finalized_bytes),
util::format_duration(session.started.elapsed()),
);
if session.failed_files > 0 {
text.push_str(&format!(", {} failed", session.failed_files));
}
self.ui.log(Category::Receive, &text);
}
SessionEndReasonV2::Cancelled => {
self.ui.log(
Category::Receive,
&format!(
"{}: cancelled by sender ({} of {} files received)",
session.alias,
session.finished_files,
session.files.len(),
),
);
}
}
self.render_status();
}
/// Cancels the active receive session: rejects further uploads on the
/// server, notifies the sender and prints the summary. Late per-file
/// results are dropped by the session-id filters.
pub(super) fn cancel_receive(&mut self) {
let Some(session) = self.receive.take() else {
return;
};
let server = self.server.clone();
let identity = self.storage.identity.clone();
let sender = session.sender.clone();
let session_id = session.session_id.clone();
tokio::spawn(async move {
server.cancel_v2_session(&session_id).await;
// Best effort: without it the sender only notices through its
// failing upload requests.
let expected_fingerprint = match sender.protocol {
ProtocolTypeV2::Https => Some(sender.fingerprint.clone()),
ProtocolTypeV2::Http => None,
};
let Ok(client) = LsHttpClientV2::try_new(
&identity.key_pem,
&identity.cert_pem,
expected_fingerprint,
Some(Duration::from_secs(5)),
) else {
return;
};
let protocol = match sender.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
};
let _ = client
.cancel(protocol, &sender.host, sender.port, &session_id)
.await;
});
self.ui.log(
Category::Receive,
&format!(
"{}: cancelled ({} of {} files received)",
session.alias,
session.finished_files,
session.files.len(),
),
);
self.render_status();
}
pub(super) fn answer_pending(&mut self, answer: Answer) {
let Some(pending) = self.pending.take() else {
return;
};
match answer {
Answer::Decline => {
let _ = pending.decision_tx.send(PrepareUploadDecisionV2::Decline);
self.ui
.log(Category::Receive, &format!("{}: Declined", pending.alias));
}
Answer::Accept | Answer::AcceptAndPair => {
self.ui
.log(Category::Receive, &format!("{}: Accepted", pending.alias));
if matches!(answer, Answer::AcceptAndPair) {
match self
.storage
.paired
.insert(pending.sender.fingerprint.clone(), pending.alias.clone())
{
Ok(()) => self.ui.log(
Category::Receive,
&format!(
"{}: Paired. Future requests are auto-accepted.",
pending.alias
),
),
Err(err) => self.ui.log(
Category::Receive,
&format!(
"{}: Paired for this run, but saving failed: {err:#}",
pending.alias
),
),
}
}
let ids: HashSet<String> = pending.files.keys().cloned().collect();
if pending
.decision_tx
.send(PrepareUploadDecisionV2::Accept(ids))
.is_err()
{
self.ui.log(
Category::Receive,
&format!("{}: request already ended", pending.alias),
);
return;
}
self.receive = Some(ReceiveSession::new(
pending.session_id,
pending.alias,
pending.sender,
pending.files,
));
}
}
}
}
+143
View File
@@ -0,0 +1,143 @@
//! The sending side: picking a target device and files, and tracking the
//! transfer driven by the [`crate::send_task`].
use super::App;
use crate::picker::{Picker, PickerOutcome};
use crate::send_task;
use crate::ui::Category;
use crate::util::SpeedMeter;
use crossterm::event::KeyEvent;
use localsend::model::transfer::FileDto;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use uuid::Uuid;
/// An outgoing transfer driven by the send task.
pub(super) struct SendState {
pub(super) session_id: Option<String>,
pub(super) alias: String,
pub(super) host: String,
pub(super) total_bytes: u64,
pub(super) sent: Arc<AtomicU64>,
pub(super) cancel: send_task::SendCancel,
pub(super) speed: SpeedMeter,
}
impl App {
pub(super) fn start_picking(&mut self, slot: u8) {
let Some(device) = self.registry.by_slot(slot) else {
self.ui
.log(Category::Send, &format!("No device on [{slot}]"));
return;
};
if self.send.is_some() {
self.ui.log(Category::Send, "A send is already in progress");
return;
}
let alias = device.alias.clone();
match Picker::open(slot) {
Ok(picker) => {
self.ui.suspend();
self.picker = Some(picker);
}
Err(err) => {
self.ui.log(
Category::Send,
&format!("{alias}: could not open the file picker: {err}"),
);
}
}
}
pub(super) fn handle_picker_key(&mut self, key: KeyEvent) {
let Some(picker) = &mut self.picker else {
return;
};
match picker.handle_key(key) {
PickerOutcome::Open => {}
PickerOutcome::Picked(files) => {
let picker = self.picker.take().unwrap();
let slot = picker.slot;
picker.close();
self.ui.resume();
self.start_send(slot, files);
}
PickerOutcome::Cancelled => {
let picker = self.picker.take().unwrap();
picker.close();
self.ui.resume();
}
}
}
fn start_send(&mut self, slot: u8, picked: Vec<PathBuf>) {
let Some(device) = self.registry.by_slot(slot).cloned() else {
return;
};
let mut files = HashMap::new();
let mut paths = HashMap::new();
let mut total_bytes = 0u64;
for path in picked {
let metadata = match std::fs::metadata(&path) {
Ok(metadata) if metadata.is_file() => metadata,
_ => {
self.ui.log(
Category::Send,
&format!("Skipping unreadable file: {}", path.display()),
);
continue;
}
};
let id = Uuid::new_v4().to_string();
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "unnamed".to_string());
total_bytes += metadata.len();
files.insert(
id.clone(),
FileDto {
id: id.clone(),
file_name,
size: metadata.len(),
file_type: mime_guess::from_path(&path)
.first_or_octet_stream()
.to_string(),
sha256: None,
preview: None,
metadata: None,
},
);
paths.insert(id, path);
}
if files.is_empty() {
self.ui.log(Category::Send, "No files selected");
return;
}
let progress = Arc::new(AtomicU64::new(0));
let cancel = send_task::SendCancel::new();
self.send = Some(SendState {
session_id: None,
alias: device.alias.clone(),
host: device.host.clone(),
total_bytes,
sent: progress.clone(),
cancel: cancel.clone(),
speed: SpeedMeter::new(),
});
tokio::spawn(send_task::run_send(
self.storage.identity.clone(),
device,
files,
paths,
progress,
cancel,
self.events_tx.clone(),
));
}
}
+97
View File
@@ -0,0 +1,97 @@
//! The status line at the bottom of the screen: one progress bar per active
//! transfer, refreshed by the tick of the main loop.
use super::App;
use crate::ui::Category;
use crate::util;
use std::sync::atomic::Ordering;
use std::time::Duration;
impl App {
pub(super) fn tick(&mut self) {
if let Some(picker) = &mut self.picker {
// Covers terminal resizes; key presses redraw on their own.
picker.draw();
return;
}
self.render_status();
}
pub(super) fn render_status(&mut self) {
const SEPARATOR: &str = " | ";
let mut transfers = Vec::new();
if let Some(session) = &mut self.receive {
let done = session.done_bytes();
let speed = session.speed.update(done);
transfers.push((
Category::Receive,
session.alias.clone(),
done,
session.total_bytes,
speed,
));
}
if let Some(send) = &mut self.send {
let done = send.sent.load(Ordering::Relaxed);
let speed = send.speed.update(done);
transfers.push((
Category::Send,
send.alias.clone(),
done,
send.total_bytes,
speed,
));
}
if transfers.is_empty() {
self.ui.set_status(None);
return;
}
// Split the terminal width among the transfers so the status line
// (mostly the flexible progress bars) spans the whole screen.
let width = util::terminal_width();
let part_width =
width.saturating_sub(1 + SEPARATOR.len() * (transfers.len() - 1)) / transfers.len();
let parts: Vec<String> = transfers
.iter()
.map(|(category, alias, done, total, speed)| {
transfer_status(*category, alias, *done, *total, *speed, part_width)
})
.collect();
self.ui.set_status(Some(parts.join(SEPARATOR)));
}
}
/// Formats one transfer for the status line, sizing the progress bar so the
/// whole entry occupies `width` visible columns.
fn transfer_status(
category: Category,
alias: &str,
done: u64,
total: u64,
speed: f64,
width: usize,
) -> String {
let fraction = match total {
0 => 1.0,
total => done as f64 / total as f64,
};
let eta = match speed > 1.0 && done < total {
true => util::format_duration(Duration::from_secs(((total - done) as f64 / speed) as u64)),
false => "--".to_string(),
};
let tail = format!(
" {} / {} [{}] [ETA: {eta}]",
util::format_bytes(done),
util::format_bytes(total),
util::format_speed(speed),
);
// Visible columns besides the bar: "T alias [" + "]" + tail, tag = 1 column.
let bar_width = width
.saturating_sub(alias.chars().count() + 5 + tail.chars().count())
.max(10);
format!(
"{} {alias} [{}]{tail}",
category.colored_tag(),
util::progress_bar(fraction, bar_width),
)
}
+49
View File
@@ -0,0 +1,49 @@
use crate::storage::Repository;
use crate::util;
use crossterm::style::Stylize;
#[rustfmt::skip]
const LOGO: [&str; 4] = [
" ▄▀ ▀ ▀▄ ",
"▄ ▄███▄ ▄",
"▀ ▀███▀ ▀",
" ▀▄ ▄ ▄▀ ",
];
pub fn render(storage: &Repository) -> String {
let logo = LOGO
.iter()
.enumerate()
.map(|(i, line)| {
let right = match i {
1 => " LocalSend CLI",
2 => concat!(" v", env!("CARGO_PKG_VERSION")),
_ => "",
};
format!("{}{right}", line.green())
})
.collect::<Vec<_>>()
.join("\n");
let listening = match util::local_ipv4_addresses() {
addresses if addresses.is_empty() => " - (no network interface found)".to_string(),
addresses => addresses
.iter()
.map(|address| format!(" - https://{address}:{}", storage.identity.port))
.collect::<Vec<_>>()
.join("\n"),
};
format!(
"{logo}\n\n{} {}\n{} {}\n{} {}\n{} {}\n{}\n{listening}\n\nReady to accept requests.\n\n",
"Alias:".green(),
storage.identity.alias,
"Port:".green(),
storage.identity.port,
"Destination:".green(),
storage.destination.display(),
"Config:".green(),
storage.dir.display(),
"Listening on:".green(),
)
}
+79
View File
@@ -0,0 +1,79 @@
use localsend::http::dto_v2::ProtocolTypeV2;
/// A discovered LocalSend device.
#[derive(Clone)]
pub struct Device {
/// The hotkey (1-9) assigned to this device, if one was free.
pub slot: Option<u8>,
pub alias: String,
/// The host to dial the device at: an IP address, or the scoped form
/// `fe80::1%3` for link-local IPv6 (the HTTP client accepts both).
pub host: String,
pub port: u16,
pub protocol: ProtocolTypeV2,
pub fingerprint: String,
}
impl Device {
pub fn slot_label(&self) -> String {
match self.slot {
Some(slot) => slot.to_string(),
None => "-".to_string(),
}
}
}
/// All devices seen in this run, identified by fingerprint.
pub struct DeviceRegistry {
devices: Vec<Device>,
}
impl DeviceRegistry {
pub fn new() -> Self {
Self {
devices: Vec::new(),
}
}
/// Adds or updates a device. Returns the device only when it is new
/// (i.e. should be logged): a known device is updated silently, because
/// multi-homed peers re-announce with a different address all the time.
pub fn upsert(
&mut self,
alias: String,
host: String,
port: u16,
protocol: ProtocolTypeV2,
fingerprint: String,
) -> Option<Device> {
if let Some(device) = self
.devices
.iter_mut()
.find(|device| device.fingerprint == fingerprint)
{
device.alias = alias;
device.host = host;
device.port = port;
device.protocol = protocol;
return None;
}
let slot =
(1..=9u8).find(|slot| !self.devices.iter().any(|device| device.slot == Some(*slot)));
let device = Device {
slot,
alias,
host,
port,
protocol,
fingerprint,
};
self.devices.push(device.clone());
Some(device)
}
pub fn by_slot(&self, slot: u8) -> Option<&Device> {
self.devices.iter().find(|device| device.slot == Some(slot))
}
}
+43 -2
View File
@@ -1,3 +1,44 @@
fn main() {
println!("Hello, world!");
mod app;
mod banner;
mod devices;
mod picker;
mod send_task;
mod storage;
mod ui;
mod util;
use clap::Parser;
use std::path::PathBuf;
/// LocalSend CLI
#[derive(Parser)]
#[command(name = "localsend-cli", version, about, after_help = HELP_SECTIONS)]
pub struct Args {
/// Device name shown to other devices [default: config.toml, else the hostname]
#[arg(long, env = "LOCALSEND_ALIAS")]
pub alias: Option<String>,
/// Port of the HTTP server [default: config.toml, else 53317]
#[arg(long, env = "LOCALSEND_PORT")]
pub port: Option<u16>,
/// Directory where received files are saved [default: config.toml, else the Downloads folder]
#[arg(long, env = "LOCALSEND_DESTINATION")]
pub destination: Option<PathBuf>,
}
const HELP_SECTIONS: &str = "Events:\n \
D Discovered a new device\n \
S Send files\n \
R Receive files\n\
\nHotkeys:\n \
1-9 Send files to the device with that number\n \
Y/N/P Accept / Decline / Accept-and-Pair an incoming request\n \
Ctrl+C Cancel the current transfer or request, or quit when idle\n \
\nEnvironment Variables:\n \
XDG_CONFIG_HOME, LOCALSEND_ALIAS, LOCALSEND_PORT, LOCALSEND_DESTINATION";
fn main() -> anyhow::Result<()> {
let args = Args::parse();
tokio::runtime::Runtime::new()?.block_on(app::run(args))
}
+322
View File
@@ -0,0 +1,322 @@
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{Clear, ClearType};
use crossterm::{cursor, execute};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, HighlightSpacing, List, ListState, Paragraph};
use ratatui_explorer::{File, FileExplorer};
use std::io::Stdout;
use std::path::PathBuf;
/// How far `PageUp` / `PageDown` jump, matching `ratatui_explorer`.
const SCROLL_COUNT: usize = 12;
/// A modal, terminal-native file picker rendered on the alternate screen
/// while the log UI is suspended.
///
/// Typing filters the listing, Space toggles files on and off, Enter confirms
/// (or descends into the highlighted directory), Esc clears the filter or
/// cancels. Confirming with an empty selection picks the highlighted file.
///
/// The explorer only supplies the directory listing; navigation and rendering
/// are done here so that the filter can hide entries.
pub struct Picker {
/// The device slot the picked files will be sent to.
pub slot: u8,
explorer: FileExplorer,
terminal: Terminal<CrosstermBackend<Stdout>>,
selected: Vec<PathBuf>,
/// The search query, matched as a case-insensitive subsequence against the
/// entry names. Empty means "show everything".
query: String,
/// Indices into `explorer.files()` that match `query`, in listing order.
matches: Vec<usize>,
list_state: ListState,
}
/// What a key press did to the picker.
pub enum PickerOutcome {
/// The picker stays open.
Open,
/// The user confirmed the selection.
Picked(Vec<PathBuf>),
/// The user cancelled.
Cancelled,
}
impl Picker {
/// Enters the alternate screen and shows the picker.
/// The caller must suspend the log UI first and resume it after [Picker::close].
pub fn open(slot: u8) -> anyhow::Result<Self> {
let explorer = FileExplorer::new()?;
execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
cursor::Hide
)?;
let terminal = Terminal::new(CrosstermBackend::new(std::io::stdout()))?;
let mut picker = Self {
slot,
explorer,
terminal,
selected: Vec::new(),
query: String::new(),
matches: Vec::new(),
list_state: ListState::default(),
};
picker.refresh_matches();
picker.select_first_entry();
picker.draw();
Ok(picker)
}
/// Leaves the alternate screen. Must be called exactly once.
pub fn close(self) {
// Clears via crossterm, not `Terminal::clear`: the latter queries the
// cursor position, whose response is read from the event stream but
// the keyboard reader thread is parked in `crossterm::event::read()`,
// so the query only ever returns after crossterm's 2s timeout.
let _ = execute!(
std::io::stdout(),
Clear(ClearType::All),
cursor::MoveTo(0, 0),
crossterm::terminal::LeaveAlternateScreen,
cursor::Show
);
}
pub fn handle_key(&mut self, key: KeyEvent) -> PickerOutcome {
match key.code {
KeyCode::Esc => {
if self.query.is_empty() {
return PickerOutcome::Cancelled;
}
self.query.clear();
self.refresh_matches();
}
KeyCode::Up => self.move_cursor(-1, true),
KeyCode::Down => self.move_cursor(1, true),
KeyCode::PageUp => self.move_cursor(-(SCROLL_COUNT as isize), false),
KeyCode::PageDown => self.move_cursor(SCROLL_COUNT as isize, false),
KeyCode::Home => self.select_match(0),
KeyCode::End => self.select_match(self.matches.len().saturating_sub(1)),
KeyCode::Left => self.navigate(key),
KeyCode::Right => {
// Never act on a hidden entry: with no matches the explorer's
// index still points at whatever was highlighted before.
if self.current().is_some_and(|file| file.is_dir) {
self.navigate(key);
}
}
KeyCode::Backspace => {
// Purely an editing key: erasing one character too many must not
// leave the directory. Left does that, and so does the `../` entry.
if self.query.pop().is_some() {
self.refresh_matches();
}
}
KeyCode::Char(' ') => {
if let Some(current) = self.current()
&& !current.is_dir
{
let path = current.path.clone();
match self.selected.iter().position(|p| *p == path) {
Some(index) => {
self.selected.remove(index);
}
None => self.selected.push(path),
}
}
}
KeyCode::Enter => match self
.current()
.map(|current| (current.is_dir, current.path.clone()))
{
Some((true, _)) => self.navigate(key),
Some((false, path)) => {
let mut files = std::mem::take(&mut self.selected);
if files.is_empty() {
files.push(path);
}
return PickerOutcome::Picked(files);
}
None => {
let files = std::mem::take(&mut self.selected);
if !files.is_empty() {
return PickerOutcome::Picked(files);
}
}
},
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.query.push(c);
self.refresh_matches();
}
_ => {}
}
self.draw();
PickerOutcome::Open
}
/// The highlighted entry, or `None` while nothing matches the query.
fn current(&self) -> Option<&File> {
self.cursor().map(|_| self.explorer.current())
}
/// Position of the highlighted entry within [Picker::matches].
fn cursor(&self) -> Option<usize> {
self.matches
.iter()
.position(|&index| index == self.explorer.selected_idx())
}
/// Changes the directory via the explorer and drops the query, since it was
/// only ever meant for the listing that is now gone.
fn navigate(&mut self, key: KeyEvent) {
let previous = self.explorer.cwd().clone();
let _ = self.explorer.handle(&Event::Key(key));
if *self.explorer.cwd() == previous {
// Nothing to navigate into — keep the query and the highlight.
return;
}
self.query.clear();
self.refresh_matches();
self.select_first_entry();
}
/// Highlights the first real entry, skipping the `../` link the explorer
/// lands on after every directory change — otherwise Right, pressed twice,
/// descends and then bounces straight back up.
fn select_first_entry(&mut self) {
let skip_parent = self.explorer.cwd().parent().is_some() && self.matches.len() > 1;
self.select_match(usize::from(skip_parent));
}
fn move_cursor(&mut self, delta: isize, wrap: bool) {
if self.matches.is_empty() {
return;
}
let len = self.matches.len() as isize;
let cursor = self.cursor().unwrap_or_default() as isize + delta;
let cursor = match wrap {
true => cursor.rem_euclid(len),
false => cursor.clamp(0, len - 1),
};
self.select_match(cursor as usize);
}
fn select_match(&mut self, cursor: usize) {
match self.matches.get(cursor) {
Some(&index) => {
self.explorer.set_selected_idx(index);
self.list_state.select(Some(cursor));
}
None => self.list_state.select(None),
}
}
/// Recomputes the visible entries. Keeps the highlighted entry if it still
/// matches, otherwise falls back to the first match.
fn refresh_matches(&mut self) {
self.matches = self
.explorer
.files()
.iter()
.enumerate()
.filter(|(_, file)| matches_query(&self.query, &file.name))
.map(|(index, _)| index)
.collect();
let cursor = self.cursor().unwrap_or_default();
self.select_match(cursor);
}
pub fn draw(&mut self) {
let Self {
explorer,
terminal,
selected,
query,
matches,
list_state,
..
} = self;
let help = format!(
" Type: filter ←/→: folder Space: select ({}) Enter: confirm Esc: clear/cancel",
selected.len()
);
let _ = terminal.draw(|frame| {
let [main_area, selected_area, help_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(frame.area());
let mut block = Block::default()
.borders(Borders::ALL)
.title_top(format!(" {} ", explorer.cwd().display()));
if !query.is_empty() {
let hits = match matches.is_empty() {
true => " no matches".to_string(),
false => format!(" {} of {}", matches.len(), explorer.files().len()),
};
block = block.title_bottom(format!(" Filter: {query} ({hits}) "));
}
let items = matches
.iter()
.filter_map(|&index| explorer.files().get(index))
.map(|file| {
let style = match file.is_dir {
true => Style::default().fg(Color::LightBlue),
false => Style::default().fg(Color::White),
};
Span::styled(file.name.clone(), style)
});
let list = List::new(items)
.block(block)
.highlight_spacing(HighlightSpacing::Always)
.highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(list, main_area, list_state);
let selected_line = match selected.is_empty() {
true => " No files selected".to_string(),
false => format!(
" Selected: {}",
selected
.iter()
.filter_map(|path| path.file_name())
.map(|name| name.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(", ")
),
};
frame.render_widget(Paragraph::new(selected_line), selected_area);
frame.render_widget(Paragraph::new(help), help_area);
});
}
}
/// Case-insensitive subsequence match, so `myrep` finds `My Report.pdf`
/// even though Space cannot be typed into the query.
fn matches_query(query: &str, name: &str) -> bool {
let name = name.to_lowercase();
let mut name = name.chars();
query
.to_lowercase()
.chars()
.all(|needle| name.any(|c| c == needle))
}
+270
View File
@@ -0,0 +1,270 @@
use crate::app::AppEvent;
use crate::devices::Device;
use crate::storage::Identity;
use crate::ui::Category;
use crate::util;
use bytes::Bytes;
use futures_util::StreamExt;
use localsend::http::client::ClientError;
use localsend::http::client::v2::LsHttpClientV2;
use localsend::http::dto::ProtocolType;
use localsend::http::dto_v2::{PrepareUploadRequestDtoV2, ProtocolTypeV2};
use localsend::model::transfer::{FileContent, FileDto};
use localsend::reqwest;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Instant;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
/// Cancellation state of a send, shared between the app and the send task.
#[derive(Clone)]
pub struct SendCancel {
pub token: CancellationToken,
/// Set (before triggering `token`) when the receiver requested the
/// cancellation; only a local cancellation still has to notify the
/// receiver.
pub by_peer: Arc<AtomicBool>,
}
impl SendCancel {
pub fn new() -> Self {
Self {
token: CancellationToken::new(),
by_peer: Arc::new(AtomicBool::new(false)),
}
}
}
/// Sends the given files to a device: prepare-upload, then one upload request
/// per accepted file. Progress is reported through `progress` (cumulative
/// bytes over all files) and log lines through `events`.
///
/// Always ends by emitting [AppEvent::SendEnded].
pub async fn run_send(
identity: Arc<Identity>,
device: Device,
files: HashMap<String, FileDto>,
paths: HashMap<String, PathBuf>,
progress: Arc<AtomicU64>,
cancel: SendCancel,
events: mpsc::Sender<AppEvent>,
) {
send_inner(identity, device, files, paths, progress, cancel, &events).await;
let _ = events.send(AppEvent::SendEnded).await;
}
async fn send_inner(
identity: Arc<Identity>,
device: Device,
files: HashMap<String, FileDto>,
paths: HashMap<String, PathBuf>,
progress: Arc<AtomicU64>,
cancel: SendCancel,
events: &mpsc::Sender<AppEvent>,
) {
let alias = device.alias.clone();
let log = |text: String| {
let events = events.clone();
async move {
let _ = events
.send(AppEvent::Log {
category: Category::Send,
text,
})
.await;
}
};
let protocol = match device.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
};
let expected_fingerprint = match device.protocol {
ProtocolTypeV2::Https => Some(device.fingerprint.clone()),
ProtocolTypeV2::Http => None,
};
let client = match LsHttpClientV2::try_new(
&identity.key_pem,
&identity.cert_pem,
expected_fingerprint,
None,
) {
Ok(client) => client,
Err(err) => {
log(format!("{alias}: Failed to create HTTP client: {err}")).await;
return;
}
};
let offered = files.len();
let payload = PrepareUploadRequestDtoV2 {
info: identity.register_dto(),
files: files.clone(),
};
let prepared = match client
.prepare_upload(
protocol.clone(),
&device.host,
device.port,
None,
payload,
None,
cancel.token.clone(),
)
.await
{
Ok(prepared) => prepared,
Err(ClientError::Cancelled) => {
log(format!("{alias}: Cancelled")).await;
return;
}
Err(ClientError::StatusCode(err)) => {
let reason = match err.status {
401 => "PIN required (not supported by the CLI)".to_string(),
403 => "Declined".to_string(),
409 => "Blocked by another session".to_string(),
429 => "Too many requests".to_string(),
status => format!(
"Request failed with status {status}{}",
err.message
.map(|message| format!(": {message}"))
.unwrap_or_default()
),
};
log(format!("{alias}: {reason}")).await;
return;
}
Err(err) => {
log(format!("{alias}: {err}")).await;
return;
}
};
let Some(response) = prepared.response else {
log(format!("{alias}: all files were declined")).await;
return;
};
let accepted_bytes: u64 = response
.files
.keys()
.filter_map(|file_id| files.get(file_id))
.map(|file| file.size)
.sum();
let _ = events
.send(AppEvent::SendSessionStarted {
session_id: response.session_id.clone(),
accepted_bytes,
})
.await;
if response.files.len() < offered {
log(format!(
"{alias}: receiver accepted {} of {offered} files",
response.files.len()
))
.await;
}
// Upload sequentially in a stable order.
let mut file_ids: Vec<&String> = response.files.keys().collect();
file_ids.sort_by_key(|file_id| &files[*file_id].file_name);
let started = Instant::now();
let mut sent_files = 0usize;
let mut sent_bytes = 0u64;
for file_id in file_ids {
let token = &response.files[file_id];
let file = &files[file_id];
let path = paths[file_id].clone();
let body = {
let progress = progress.clone();
let base = sent_bytes;
upload_body(FileContent::Path(path), move |bytes_of_file| {
progress.store(base + bytes_of_file, Ordering::Relaxed);
})
};
match client
.upload(
protocol.clone(),
&device.host,
device.port,
None,
&response.session_id,
file_id,
token,
body,
cancel.token.clone(),
)
.await
{
Ok(()) => {
sent_files += 1;
sent_bytes += file.size;
progress.store(sent_bytes, Ordering::Relaxed);
}
Err(ClientError::Cancelled) => {
if cancel.by_peer.load(Ordering::Relaxed) {
log(format!(
"{alias}: cancelled by receiver ({sent_files} file(s) sent)"
))
.await;
} else {
// Cancelled locally: the receiver does not know yet.
let _ = client
.cancel(
protocol.clone(),
&device.host,
device.port,
&response.session_id,
)
.await;
log(format!("{alias}: cancelled ({sent_files} file(s) sent)")).await;
}
return;
}
Err(err) => {
log(format!(
"{alias}: failed to upload {}: {err}",
file.file_name
))
.await;
let _ = client
.cancel(
protocol.clone(),
&device.host,
device.port,
&response.session_id,
)
.await;
return;
}
}
}
log(format!(
"{alias}: Sent {sent_files} file{} ({}, took {})",
if sent_files == 1 { "" } else { "s" },
util::format_bytes(sent_bytes),
util::format_duration(started.elapsed()),
))
.await;
}
/// Builds a streaming request body from the file content, invoking `progress`
/// with the cumulative number of bytes of this file as chunks are sent.
fn upload_body(content: FileContent, progress: impl Fn(u64) + Send + 'static) -> reqwest::Body {
let mut sent = 0u64;
let stream = ReceiverStream::new(content.into_receiver()).map(move |chunk| {
sent += chunk.len() as u64;
progress(sent);
Ok::<Bytes, anyhow::Error>(chunk)
});
reqwest::Body::wrap_stream(stream)
}
+92
View File
@@ -0,0 +1,92 @@
//! `config.toml`: optional user settings. A commented template is written
//! on the first run; command-line flags and environment variables take
//! precedence.
use crate::Args;
use anyhow::Context;
use serde::Deserialize;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub alias: Option<String>,
pub port: Option<u16>,
pub destination: Option<PathBuf>,
}
const CONFIG_TEMPLATE: &str = "\
# LocalSend CLI configuration. Command-line flags and environment variables
# (LOCALSEND_ALIAS, LOCALSEND_PORT, LOCALSEND_DESTINATION) take precedence.
# Device name shown to other devices (default: the hostname).
#alias = \"My Device\"
# Port of the HTTP server.
#port = 53317
# Directory where received files are saved (default: the system Downloads folder).
#destination = \"~/Downloads\"
";
/// Default port of the HTTP server.
const DEFAULT_PORT: u16 = 53317;
/// The settings actually used by the app.
pub struct ResolvedConfig {
pub alias: String,
pub port: u16,
pub destination: PathBuf,
}
/// Reads `config.toml` and resolves every setting, in order of precedence:
/// command-line flag > environment variable > config file > default
///
/// The environment variables are read by clap.
pub fn load_with_fallback(dir: &Path, args: &Args) -> anyhow::Result<ResolvedConfig> {
let config = load(dir)?;
Ok(ResolvedConfig {
alias: args
.alias
.clone()
.or(config.alias)
.unwrap_or_else(default_alias),
port: args.port.or(config.port).unwrap_or(DEFAULT_PORT),
destination: match args.destination.clone().or(config.destination) {
Some(destination) => expand_tilde(destination),
None => dirs::download_dir().unwrap_or_else(|| PathBuf::from(".")),
},
})
}
/// Reads `config.toml`, writing a commented template when it is missing.
fn load(dir: &Path) -> anyhow::Result<Config> {
let path = dir.join("config.toml");
match std::fs::read_to_string(&path) {
Ok(text) => toml::from_str(&text)
.with_context(|| format!("Invalid config file: {}", path.display())),
Err(err) if err.kind() == ErrorKind::NotFound => {
// Best effort: a missing template is no reason to refuse to run.
let _ = std::fs::write(&path, CONFIG_TEMPLATE);
Ok(Config::default())
}
Err(err) => Err(err).context(format!("Could not read {}", path.display())),
}
}
fn default_alias() -> String {
gethostname::gethostname()
.to_string_lossy()
.trim_end_matches(".local")
.to_string()
}
/// Replaces a leading `~/` with the home directory; config values are not
/// expanded by the shell.
fn expand_tilde(path: PathBuf) -> PathBuf {
match (path.strip_prefix("~"), dirs::home_dir()) {
(Ok(rest), Some(home)) => home.join(rest),
_ => path,
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `identity.pem`: this device's certificate and private key.
use anyhow::Context;
use localsend::crypto::cert::fingerprint_from_cert_der;
use localsend::http::dto_v2::{PROTOCOL_VERSION_V2, ProtocolTypeV2, RegisterDtoV2};
use localsend::http::server::TlsConfig;
use localsend::http::state::ClientInfo;
use localsend::model::discovery::DeviceType;
use localsend::multicast::MulticastDevice;
use std::path::Path;
/// This device's identity: a self-signed certificate whose SHA-256
/// fingerprint identifies the device. The certificate is persisted as
/// `identity.pem` so the fingerprint — and thereby pairings, on both
/// sides — survives restarts.
pub struct Identity {
pub alias: String,
pub port: u16,
pub cert_pem: String,
pub key_pem: String,
pub fingerprint: String,
}
impl Identity {
/// Loads the identity from `identity.pem` in `dir`, generating and
/// saving a fresh one when the file does not exist yet.
pub fn load_or_generate(dir: &Path, alias: String, port: u16) -> anyhow::Result<Self> {
let path = dir.join("identity.pem");
match std::fs::read_to_string(&path) {
Ok(text) => Self::from_pem(&text, alias, port).with_context(|| {
format!(
"Invalid identity file: {} (delete it to generate a new identity; \
other devices will then see this device as unpaired)",
path.display()
)
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let identity = Self::generate(alias, port)?;
identity
.save(&path)
.with_context(|| format!("Could not save {}", path.display()))?;
Ok(identity)
}
Err(err) => Err(err).context(format!("Could not read {}", path.display())),
}
}
fn from_pem(text: &str, alias: String, port: u16) -> anyhow::Result<Self> {
let blocks = pem::parse_many(text)?;
let cert = blocks
.iter()
.find(|block| block.tag() == "CERTIFICATE")
.context("missing CERTIFICATE block")?;
let key = blocks
.iter()
.find(|block| block.tag().ends_with("PRIVATE KEY"))
.context("missing PRIVATE KEY block")?;
let key_pem = pem::encode(key);
rcgen::KeyPair::from_pem(&key_pem).context("unusable private key")?;
Ok(Self {
alias,
port,
fingerprint: fingerprint_from_cert_der(cert.contents()),
cert_pem: pem::encode(cert),
key_pem,
})
}
fn save(&self, path: &Path) -> anyhow::Result<()> {
let contents = format!("{}{}", self.cert_pem, self.key_pem);
#[cfg(unix)]
{
// The file contains the private key; keep it owner-readable only.
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())?;
}
#[cfg(not(unix))]
std::fs::write(path, contents)?;
Ok(())
}
fn generate(alias: String, port: u16) -> anyhow::Result<Self> {
let key_pair = rcgen::KeyPair::generate()?;
let mut params = rcgen::CertificateParams::default();
params.distinguished_name = rcgen::DistinguishedName::new();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "LocalSend User");
let cert = params.self_signed(&key_pair)?;
Ok(Self {
alias,
port,
fingerprint: fingerprint_from_cert_der(cert.der()),
cert_pem: cert.pem(),
key_pem: key_pair.serialize_pem(),
})
}
pub fn tls_config(&self) -> TlsConfig {
TlsConfig {
cert: self.cert_pem.clone(),
private_key: self.key_pem.clone(),
}
}
pub fn client_info(&self) -> ClientInfo {
ClientInfo {
alias: self.alias.clone(),
version: PROTOCOL_VERSION_V2.to_string(),
device_model: Some("CLI".to_string()),
device_type: Some(DeviceType::Headless),
token: self.fingerprint.clone(),
}
}
pub fn register_dto(&self) -> RegisterDtoV2 {
RegisterDtoV2 {
alias: self.alias.clone(),
version: PROTOCOL_VERSION_V2.to_string(),
device_model: Some("CLI".to_string()),
device_type: Some(DeviceType::Headless),
fingerprint: self.fingerprint.clone(),
port: self.port,
protocol: ProtocolTypeV2::Https,
download: false,
}
}
pub fn multicast_device(&self) -> MulticastDevice {
MulticastDevice {
alias: self.alias.clone(),
version: PROTOCOL_VERSION_V2.to_string(),
device_model: Some("CLI".to_string()),
device_type: Some(DeviceType::Headless),
fingerprint: self.fingerprint.clone(),
port: self.port,
protocol: ProtocolTypeV2::Https,
download: false,
}
}
}
+74
View File
@@ -0,0 +1,74 @@
//! The CLI's persistence layer. All persistent files live in one directory
//! (see [`Repository::dir`]) and are loaded through the unified
//! [`Repository`]:
//!
//! - `config.toml` ([`config`]): user-edited settings; a commented template
//! is written on the first run.
//! - `identity.pem` ([`identity`]): this device's certificate and private
//! key.
//! - `paired.json` ([`paired`]): paired devices; machine-written.
mod config;
mod identity;
mod paired;
pub use identity::Identity;
pub use paired::PairedDevices;
use crate::Args;
use anyhow::Context;
use std::path::PathBuf;
use std::sync::Arc;
/// Everything the CLI persists, loaded (or initialized) at startup.
pub struct Repository {
/// The directory holding all persistent files.
pub dir: PathBuf,
/// Directory where received files are saved.
pub destination: PathBuf,
/// This device's identity from `identity.pem`, with the resolved alias
/// and port applied.
pub identity: Arc<Identity>,
/// Devices whose transfer requests are auto-accepted; persisted across
/// runs as `paired.json`.
pub paired: PairedDevices,
}
impl Repository {
/// Creates the storage directory if needed and loads every file in it,
/// writing the config template and generating the identity on the
/// first run.
pub fn load(args: &Args) -> anyhow::Result<Self> {
let dir = dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("Could not create {}", dir.display()))?;
let config = config::load_with_fallback(&dir, args)?;
let paired = PairedDevices::load(&dir)?;
let identity = Arc::new(Identity::load_or_generate(&dir, config.alias, config.port)?);
Ok(Self {
dir,
destination: config.destination,
identity,
paired,
})
}
}
/// The directory holding all persistent files:
/// `$XDG_CONFIG_HOME/localsend-cli`, or `~/.config/localsend-cli`.
///
/// `~/.config` is used on every platform instead of `dirs::config_dir()`:
/// terminal tools conventionally keep their config there even on macOS, and
/// the `-cli` suffix keeps the directory separate from the Flutter app's.
fn dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
// The XDG spec says to ignore relative paths.
.filter(|path| path.is_absolute())
.or_else(|| dirs::home_dir().map(|home| home.join(".config")))
.unwrap_or_else(|| PathBuf::from("."))
.join("localsend-cli")
}
+95
View File
@@ -0,0 +1,95 @@
//! `paired.json`: paired devices; machine-written.
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
/// Current schema version of `paired.json`. On a schema change,
/// bump this and migrate the old versions in [`PairedDevices::load`].
///
/// Planned v2: trust the peer's permanent ed25519 public key (see
/// `crypto::token` in the core crate) instead of its certificate
/// fingerprint; certificates (RSA) then become ephemeral.
const PAIRED_DEVICES_VERSION: u32 = 1;
#[derive(Serialize, Deserialize)]
pub struct PairedDevice {
pub alias: String,
}
/// The on-disk format of `paired.json`.
#[derive(Serialize, Deserialize)]
struct PairedDevicesFile {
version: u32,
devices: BTreeMap<String, PairedDevice>,
}
/// The paired devices from `paired.json`, keyed by certificate
/// fingerprint. Their transfer requests are accepted without asking.
pub struct PairedDevices {
path: PathBuf,
file: PairedDevicesFile,
}
impl PairedDevices {
pub fn load(dir: &Path) -> anyhow::Result<Self> {
let path = dir.join("paired.json");
let devices = match std::fs::read_to_string(&path) {
Ok(text) => {
// Read the version on its own first: old versions are parsed
// by their own (migration) arm, not by the current schema.
#[derive(Deserialize)]
struct Version {
version: u32,
}
let context = || format!("Invalid paired devices file: {}", path.display());
let Version { version } = serde_json::from_str(&text).with_context(context)?;
match version {
PAIRED_DEVICES_VERSION => {
serde_json::from_str::<PairedDevicesFile>(&text)
.with_context(context)?
.devices
}
version => anyhow::bail!(
"{} has version {version}, but this build supports only version {PAIRED_DEVICES_VERSION}. \
Was it written by a newer LocalSend CLI?",
path.display()
),
}
}
Err(err) if err.kind() == ErrorKind::NotFound => BTreeMap::new(),
Err(err) => return Err(err).context(format!("Could not read {}", path.display())),
};
Ok(Self {
path,
file: PairedDevicesFile {
version: PAIRED_DEVICES_VERSION,
devices,
},
})
}
pub fn contains(&self, fingerprint: &str) -> bool {
self.file.devices.contains_key(fingerprint)
}
/// Adds a device and saves the file. The device stays paired for this
/// run even when saving fails.
pub fn insert(&mut self, fingerprint: String, alias: String) -> anyhow::Result<()> {
self.file
.devices
.insert(fingerprint, PairedDevice { alias });
self.save()
}
fn save(&self) -> anyhow::Result<()> {
// Write-then-rename so a crash cannot leave a truncated file.
let temp = self.path.with_extension("json.tmp");
std::fs::write(&temp, serde_json::to_string_pretty(&self.file)?)
.with_context(|| format!("Could not write {}", temp.display()))?;
std::fs::rename(&temp, &self.path)
.with_context(|| format!("Could not write {}", self.path.display()))
}
}
+184
View File
@@ -0,0 +1,184 @@
use crate::util;
use crossterm::style::{Color, Print, Stylize};
use crossterm::terminal::{Clear, ClearType};
use crossterm::{QueueableCommand, cursor};
use std::io::{Stdout, Write, stdout};
/// The category of a log event, shown as its colored `D` / `R` / `S` tag.
#[derive(Clone, Copy, Debug)]
pub enum Category {
Discovery,
Receive,
Send,
}
impl Category {
pub fn tag(self) -> &'static str {
match self {
Category::Discovery => "D",
Category::Receive => "R",
Category::Send => "S",
}
}
fn color(self) -> Color {
match self {
Category::Discovery => Color::Cyan,
Category::Receive => Color::Green,
Category::Send => Color::Magenta,
}
}
pub fn colored_tag(self) -> String {
self.tag().with(self.color()).to_string()
}
}
/// An append-only log (like `docker logs`) with a single in-place updated
/// status line at the bottom for transfer progress.
///
/// The terminal is in raw mode, so every line break must be `\r\n`.
pub struct Ui {
out: Stdout,
status: Option<String>,
/// While suspended (the file picker owns the alternate screen), log lines
/// are buffered and flushed on [Ui::resume].
suspended: bool,
buffer: Vec<String>,
}
impl Ui {
pub fn new() -> Self {
Self {
out: stdout(),
status: None,
suspended: false,
buffer: Vec::new(),
}
}
/// Stops writing to the terminal; log lines are buffered instead. The
/// picker draws on the alternate screen, so the main screen stays intact
/// underneath.
pub fn suspend(&mut self) {
self.suspended = true;
}
/// Resumes terminal output and flushes the buffered log lines.
pub fn resume(&mut self) {
self.suspended = false;
for line in std::mem::take(&mut self.buffer) {
let _ = self.out.queue(Print(line));
}
self.redraw_status();
let _ = self.out.flush();
}
/// Prints a log block: the first line is prefixed with the category tag,
/// further lines are indented below it.
pub fn log(&mut self, category: Category, text: &str) {
self.print_block(Some(category), text);
}
/// Prints a log line that is not tied to an event category.
pub fn log_plain(&mut self, text: &str) {
self.print_block(None, text);
}
fn print_block(&mut self, category: Option<Category>, text: &str) {
self.clear_status_line();
for (i, line) in text.lines().enumerate() {
let formatted = match (i, category) {
(0, Some(category)) => format!("{} {line}\r\n", category.colored_tag()),
(0, None) => format!("{line}\r\n"),
(_, Some(_)) => format!(" {line}\r\n"),
(_, None) => format!("{line}\r\n"),
};
match self.suspended {
true => self.buffer.push(formatted),
false => {
let _ = self.out.queue(Print(formatted));
}
}
}
self.redraw_status();
let _ = self.out.flush();
}
/// Replaces the status line at the bottom, or removes it with `None`.
pub fn set_status(&mut self, status: Option<String>) {
if status == self.status {
return;
}
self.clear_status_line();
self.status = status;
self.redraw_status();
let _ = self.out.flush();
}
fn clear_status_line(&mut self) {
if !self.suspended && self.status.is_some() {
let _ = self.out.queue(cursor::MoveToColumn(0));
let _ = self.out.queue(Clear(ClearType::CurrentLine));
}
}
fn redraw_status(&mut self) {
if self.suspended {
return;
}
if let Some(status) = &self.status {
let line = truncate_visible(status, util::terminal_width().saturating_sub(1));
let _ = self.out.queue(Print(line));
}
}
}
/// A piece of a string that may contain ANSI escape sequences: either a
/// sequence, which takes no columns, or a single visible character.
enum Segment<'a> {
Escape(&'a str),
Visible(char),
}
/// Splits `s` into escape sequences and visible characters, so that everything
/// measuring or cutting a formatted string agrees on what occupies a column.
///
/// A trailing escape sequence that is never terminated runs to the end of `s`.
fn segments(s: &str) -> impl Iterator<Item = Segment<'_>> {
let mut iter = s.char_indices();
std::iter::from_fn(move || {
let (start, c) = iter.next()?;
if c != '\u{1b}' {
return Some(Segment::Visible(c));
}
// A CSI sequence like `\x1b[36m` is terminated by a letter.
let mut end = start + c.len_utf8();
for (i, c) in iter.by_ref() {
end = i + c.len_utf8();
if c.is_ascii_alphabetic() {
break;
}
}
Some(Segment::Escape(&s[start..end]))
})
}
/// Truncates to at most `max` visible columns, keeping ANSI escape sequences
/// intact (they take no columns and must not be cut in half).
fn truncate_visible(s: &str, max: usize) -> String {
let mut out = String::new();
let mut visible = 0usize;
for segment in segments(s) {
match segment {
Segment::Escape(escape) => out.push_str(escape),
Segment::Visible(_) if visible == max => break,
Segment::Visible(c) => {
visible += 1;
out.push(c);
}
}
}
out
}
+125
View File
@@ -0,0 +1,125 @@
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1000.0 && unit < UNITS.len() - 1 {
value /= 1000.0;
unit += 1;
}
match unit {
0 => format!("{bytes} B"),
_ => format!("{value:.1} {}", UNITS[unit]),
}
}
pub fn format_speed(bytes_per_sec: f64) -> String {
format!("{}/s", format_bytes(bytes_per_sec.max(0.0) as u64))
}
pub fn format_duration(duration: Duration) -> String {
let secs = duration.as_secs();
let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
if h > 0 {
format!("{h}h {m}m")
} else if m > 0 {
format!("{m}m {s}s")
} else {
format!("{s}s")
}
}
/// The width of the terminal in columns, falling back to 120 when it cannot be
/// determined (e.g. when the output is not a terminal).
pub fn terminal_width() -> usize {
crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(120)
}
pub fn progress_bar(fraction: f64, width: usize) -> String {
let filled = (fraction.clamp(0.0, 1.0) * width as f64).round() as usize;
format!("{}{}", "#".repeat(filled), "-".repeat(width - filled))
}
/// A path in `dir` for `file_name` that does not exist yet, appending
/// ` (1)`, ` (2)`, … before the extension on collisions.
///
/// Only the final path component of `file_name` is used, so a malicious
/// sender cannot escape the target directory.
pub fn unique_path(dir: &Path, file_name: &str) -> PathBuf {
let name = Path::new(file_name)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "unnamed".to_string());
let candidate = dir.join(&name);
if !candidate.exists() {
return candidate;
}
let (stem, extension) = match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => (stem, format!(".{extension}")),
_ => (name.as_str(), String::new()),
};
(1..)
.map(|i| dir.join(format!("{stem} ({i}){extension}")))
.find(|candidate| !candidate.exists())
.unwrap()
}
/// The IPv4 addresses of all non-loopback interfaces, i.e. the addresses this
/// device can be reached at. Empty when the interfaces cannot be enumerated.
pub fn local_ipv4_addresses() -> Vec<Ipv4Addr> {
let Ok(interfaces) = if_addrs::get_if_addrs() else {
return Vec::new();
};
let mut addresses: Vec<Ipv4Addr> = interfaces
.into_iter()
.filter(|interface| !interface.is_loopback())
.filter_map(|interface| match interface.ip() {
std::net::IpAddr::V4(address) => Some(address),
std::net::IpAddr::V6(_) => None,
})
.collect();
addresses.sort();
addresses.dedup();
addresses
}
/// Estimates the transfer speed from cumulative byte counts, smoothed with an
/// exponential moving average.
pub struct SpeedMeter {
last_bytes: u64,
last_time: Instant,
ema: f64,
}
impl SpeedMeter {
pub fn new() -> Self {
Self {
last_bytes: 0,
last_time: Instant::now(),
ema: 0.0,
}
}
pub fn update(&mut self, bytes_now: u64) -> f64 {
let now = Instant::now();
let dt = now.duration_since(self.last_time).as_secs_f64();
if dt < 0.1 {
return self.ema;
}
let instantaneous = bytes_now.saturating_sub(self.last_bytes) as f64 / dt;
self.ema = match self.ema {
0.0 => instantaneous,
ema => ema * 0.7 + instantaneous * 0.3,
};
self.last_bytes = bytes_now;
self.last_time = now;
self.ema
}
}
+3 -2
View File
@@ -109,17 +109,18 @@ impl LsHttpClient {
public_key: Option<String>,
payload: http::dto::PrepareUploadRequestDto,
pin: Option<&str>,
cancel: tokio_util::sync::CancellationToken,
) -> Result<http::dto::PrepareUploadResult, ClientError> {
match self {
LsHttpClient::V2(client) => {
let result = client
.prepare_upload(protocol, ip, port, public_key, payload.into(), pin)
.prepare_upload(protocol, ip, port, public_key, payload.into(), pin, cancel)
.await?;
Ok(result.into())
}
LsHttpClient::V3(client) => {
client
.prepare_upload(protocol, ip, port, public_key, payload)
.prepare_upload(protocol, ip, port, public_key, payload, cancel)
.await
}
}
+12 -3
View File
@@ -119,6 +119,10 @@ impl LsHttpClientV2 {
/// * `public_key` - Expected public key for verification (HTTPS only)
/// * `payload` - Upload request with device info and file metadata
/// * `pin` - Optional PIN if required by receiver
/// * `cancel` - Cancellation token; cancelling it aborts the request with
/// [`ClientError::Cancelled`]. Aborting closes the connection, which
/// tells the receiver that the sender is no longer waiting for a
/// decision.
///
/// # Returns
/// Session ID and accepted file tokens, or an error.
@@ -139,6 +143,7 @@ impl LsHttpClientV2 {
public_key: Option<String>,
payload: PrepareUploadRequestDtoV2,
pin: Option<&str>,
cancel: CancellationToken,
) -> Result<PrepareUploadResultV2, ClientError> {
let pin_params: &[(&'static str, &str)] = match &pin {
Some(pin) => &[("pin", pin)],
@@ -154,13 +159,17 @@ impl LsHttpClientV2 {
}
.to_string();
let res = self
let send = self
.client
.post(&url)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&payload)?)
.send()
.await?;
.send();
let res = tokio::select! {
res = send => res?,
_ = cancel.cancelled() => return Err(ClientError::Cancelled),
};
if protocol == ProtocolType::Https {
super::verify_cert_from_res(&res, public_key)?;
+11 -3
View File
@@ -133,6 +133,9 @@ impl LsHttpClientV3 {
Ok(ResultWithPublicKey { public_key, body })
}
/// `cancel` is a cancellation token; cancelling it aborts the request with
/// [`ClientError::Cancelled`]. Aborting closes the connection, which tells
/// the receiver that the sender is no longer waiting for a decision.
pub async fn prepare_upload(
&self,
protocol: ProtocolType,
@@ -140,8 +143,9 @@ impl LsHttpClientV3 {
port: u16,
public_key: Option<String>,
payload: http::dto::PrepareUploadRequestDto,
cancel: CancellationToken,
) -> Result<http::dto::PrepareUploadResult, ClientError> {
let res = self
let send = self
.client
.post(
TargetUrl {
@@ -155,8 +159,12 @@ impl LsHttpClientV3 {
.to_string(),
)
.body(serde_json::to_string(&payload)?)
.send()
.await?;
.send();
let res = tokio::select! {
res = send => res?,
_ = cancel.cancelled() => return Err(ClientError::Cancelled),
};
if protocol == ProtocolType::Https {
super::verify_cert_from_res(&res, public_key)?;
@@ -1,16 +1,32 @@
use crate::http::server::PeerIp;
use crate::model::transfer::FileDto;
use std::collections::HashMap;
use tokio_util::sync::CancellationToken;
/// State of the single v2 upload session slot.
pub(crate) enum SessionStateV2 {
/// A prepare-upload request is waiting for the application's decision.
Pending,
Pending(PendingSessionV2),
/// An accepted upload session.
Active(UploadSessionV2),
}
/// A prepare-upload request that is waiting for the application's decision.
///
/// Senders on protocol 2.0/2.1 do not know the session ID before the
/// prepare-upload response, so they cancel the pending request with a
/// session-less `POST /cancel` from the same address; `cancel` interrupts
/// the waiting request handler.
pub(crate) struct PendingSessionV2 {
pub(crate) session_id: String,
/// The IP address of the sender. Only this address may cancel the request.
pub(crate) sender_ip: PeerIp,
pub(crate) cancel: CancellationToken,
}
pub(crate) struct UploadSessionV2 {
pub(crate) session_id: String,
+1 -1
View File
@@ -138,7 +138,7 @@ impl ServerHandle {
/// e.g. because the user aborted the transfer on the receiving side.
///
/// Uploads that are already in progress still run to completion, but new
/// upload requests are rejected and a new session can be created.
/// upload requests fail and a new session can be created.
/// No [ServerEventV2::SessionEnd] is emitted: the application initiated
/// the cancellation itself.
///
+54 -9
View File
@@ -9,7 +9,7 @@ use crate::http::server::common::query::parse_query;
use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse};
use crate::http::server::common::save::{FileUploadTarget, SaveResult};
use crate::http::server::common::session::{
FileStatusV2, SessionFileV2, SessionStateV2, UploadSessionV2,
FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2,
};
use crate::http::server::PeerIp;
use crate::http::server::{common, AppState, RequestClientInfo, V2State};
@@ -19,6 +19,7 @@ use hyper::{Request, Response, StatusCode};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
/// Events emitted by the v2 HTTP server that must be handled by the application.
@@ -229,6 +230,9 @@ pub(crate) async fn prepare_upload(
return Err(AppError::BadRequest("No files provided".to_string()));
}
let session_id = Uuid::new_v4().to_string();
let cancelled = CancellationToken::new();
// Claim the single session slot.
{
let mut slot = v2.session.lock().await;
@@ -238,11 +242,13 @@ pub(crate) async fn prepare_upload(
"Blocked by another session".to_string(),
));
}
*slot = Some(SessionStateV2::Pending);
*slot = Some(SessionStateV2::Pending(PendingSessionV2 {
session_id: session_id.clone(),
sender_ip: client_info.ip,
cancel: cancelled.clone(),
}));
}
let session_id = Uuid::new_v4().to_string();
// Frees the slot again if this request is aborted before a session is created.
let mut pending_guard = PendingSessionGuard::new(v2.clone(), session_id.clone());
@@ -259,9 +265,20 @@ pub(crate) async fn prepare_upload(
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
let decision = decision_rx
.await
.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?;
// The sender may cancel the request while the application is deciding.
// Returning with the guard still armed frees the slot and emits
// [ServerEventV2::PrepareUploadAborted], like a dropped connection.
let decision = tokio::select! {
decision = decision_rx => {
decision.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?
}
_ = cancelled.cancelled() => {
return Err(AppError::Message(
StatusCode::FORBIDDEN,
"Cancelled by sender".to_string(),
));
}
};
let accepted_ids = match decision {
PrepareUploadDecisionV2::Decline => {
@@ -408,8 +425,36 @@ pub(crate) async fn cancel(
) -> Result<Response<BoxedBody>, AppError> {
let v2 = require_v2(&state)?;
let query = parse_query(req.uri().query());
let session_id = query.get("sessionId");
if let Some(session_id) = query.get("sessionId") {
// A pending prepare-upload request: the sender does not know the session
// ID yet (it is part of the response), so a cancel from the pending
// sender's address is accepted without one.
let pending_cancelled = {
let slot = v2.session.lock().await;
match slot.as_ref() {
Some(SessionStateV2::Pending(pending))
if pending.sender_ip == client_info.ip
&& session_id.is_none_or(|id| *id == pending.session_id) =>
{
tracing::info!(
"Pending upload session cancelled by sender: {}",
pending.session_id
);
// The waiting prepare-upload handler frees the slot and
// notifies the application.
pending.cancel.cancel();
true
}
_ => false,
}
};
if pending_cancelled {
return Ok(Response::new(empty_body()));
}
if let Some(session_id) = session_id {
let cancelled = {
let mut slot = v2.session.lock().await;
match slot.as_ref() {
@@ -515,7 +560,7 @@ impl Drop for PendingSessionGuard {
async fn clear_pending_session(v2: &V2State) {
let mut slot = v2.session.lock().await;
if matches!(*slot, Some(SessionStateV2::Pending)) {
if matches!(*slot, Some(SessionStateV2::Pending(_))) {
*slot = None;
}
}
+371
View File
@@ -310,6 +310,7 @@ async fn test_full_upload_flow() {
None,
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
None,
CancellationToken::new(),
)
.await
.unwrap();
@@ -386,6 +387,7 @@ async fn test_upload_with_matching_sha256() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -423,6 +425,7 @@ async fn test_upload_with_mismatched_sha256() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -459,6 +462,7 @@ async fn test_upload_retry_after_mismatched_sha256() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -522,6 +526,7 @@ async fn test_upload_retry_reuses_the_same_path() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -583,6 +588,7 @@ async fn test_upload_mismatched_sha256_attempts_exhausted() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -639,6 +645,7 @@ async fn test_upload_saved_to_path_by_server() {
None,
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
None,
CancellationToken::new(),
)
.await
.unwrap();
@@ -699,6 +706,7 @@ async fn test_upload_with_invalid_token() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -759,6 +767,7 @@ async fn test_second_session_blocked_and_cancel() {
None,
prepare_upload_request(&[file.clone()]),
None,
CancellationToken::new(),
)
.await
.unwrap()
@@ -774,6 +783,7 @@ async fn test_second_session_blocked_and_cancel() {
None,
prepare_upload_request(&[file.clone()]),
None,
CancellationToken::new(),
)
.await;
assert_status(result, 409);
@@ -804,11 +814,365 @@ async fn test_second_session_blocked_and_cancel() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await
.unwrap();
}
/// The sender aborts the prepare-upload request (drops the connection) while
/// the receiving application is still deciding. The application must be told
/// via [ServerEventV2::PrepareUploadAborted] and the session slot must be
/// freed so the next request is not blocked.
#[tokio::test]
async fn test_prepare_upload_aborted_by_sender_disconnect() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
let (aborted_tx, aborted_rx) = oneshot::channel::<String>();
// Unlike the shared harness, this event loop does not answer the first
// prepare-upload decision: the request stays pending like a real
// application waiting for user input. Later requests are declined so the
// test can verify the slot was freed without hanging.
tokio::spawn(async move {
let mut held_decision = None;
let mut aborted_tx = Some(aborted_tx);
while let Some(event) = event_rx.recv().await {
match event {
ServerEventV2::PrepareUpload { decision_tx, .. } => {
if held_decision.is_none() {
held_decision = Some(decision_tx);
} else {
let _ = decision_tx.send(PrepareUploadDecisionV2::Decline);
}
}
ServerEventV2::PrepareUploadAborted { session_id } => {
if let Some(tx) = aborted_tx.take() {
let _ = tx.send(session_id);
}
}
_ => {}
}
}
});
let (stop_tx, stop_rx) = oneshot::channel::<()>();
start_with_port(
port,
None, // plain HTTP
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
None,
Some(ServerConfigV2 {
pin: None,
event_tx,
}),
None,
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
// Raw TCP so the connection can be closed mid-request.
let body =
serde_json::to_string(&prepare_upload_request(&[file_dto("file-a", "a.bin", 5)])).unwrap();
let request = format!(
"POST /api/localsend/v2/prepare-upload HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.unwrap();
tokio::io::AsyncWriteExt::write_all(&mut stream, request.as_bytes())
.await
.unwrap();
// Give the server time to read the request and emit PrepareUpload,
// then hang up without waiting for the response.
tokio::time::sleep(Duration::from_millis(200)).await;
drop(stream);
tokio::time::timeout(Duration::from_secs(3), aborted_rx)
.await
.expect("PrepareUploadAborted was not emitted after the sender disconnected")
.unwrap();
// The pending slot must be free again for the next sender.
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
port,
None,
prepare_upload_request(&[file_dto("file-b", "b.bin", 5)]),
None,
CancellationToken::new(),
)
.await;
// The event loop declines the second request; 409 would mean the
// pending slot of the aborted request leaked.
assert_status(result, 403);
let _ = stop_tx.send(());
}
/// A released (Dart) sender cancels a pending prepare-upload with a
/// session-less `POST /cancel` while keeping the prepare-upload request open:
/// it does not know the session ID (that is part of the response it never
/// waits for) and does not abort the connection. The pending request must be
/// rejected, the application notified, and the slot freed.
#[tokio::test]
async fn test_prepare_upload_cancelled_by_session_less_cancel() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
let (aborted_tx, mut aborted_rx) = oneshot::channel::<String>();
tokio::spawn(async move {
let mut held_decision = None;
let mut aborted_tx = Some(aborted_tx);
while let Some(event) = event_rx.recv().await {
match event {
ServerEventV2::PrepareUpload { decision_tx, .. } => {
if held_decision.is_none() {
held_decision = Some(decision_tx);
} else {
let _ = decision_tx.send(PrepareUploadDecisionV2::Decline);
}
}
ServerEventV2::PrepareUploadAborted { session_id } => {
if let Some(tx) = aborted_tx.take() {
let _ = tx.send(session_id);
}
}
_ => {}
}
}
});
let (stop_tx, stop_rx) = oneshot::channel::<()>();
start_with_port(
port,
None, // plain HTTP
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
None,
Some(ServerConfigV2 {
pin: None,
event_tx,
}),
None,
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
// The prepare-upload request stays open in the background, like the
// released sender that fires the cancel without aborting it.
let prepare_task = tokio::spawn(async move {
let client = LsHttpClientV2::try_new_without_cert().unwrap();
client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
port,
None,
prepare_upload_request(&[file_dto("file-a", "a.bin", 5)]),
None,
CancellationToken::new(),
)
.await
});
tokio::time::sleep(Duration::from_millis(200)).await;
let cancel_url = format!("http://127.0.0.1:{port}/api/localsend/v2/cancel");
let cancel_client = localsend::reqwest::Client::new();
// A cancel with a wrong session ID must not cancel the pending request.
cancel_client
.post(format!("{cancel_url}?sessionId=some-other-session"))
.send()
.await
.unwrap();
assert!(
tokio::time::timeout(Duration::from_millis(300), &mut aborted_rx)
.await
.is_err(),
"a cancel with a foreign session ID must not abort the pending request"
);
// The session-less cancel of the released sender.
cancel_client.post(&cancel_url).send().await.unwrap();
tokio::time::timeout(Duration::from_secs(3), aborted_rx)
.await
.expect("PrepareUploadAborted was not emitted after the session-less cancel")
.unwrap();
// The open prepare-upload request is answered with a rejection.
let result = prepare_task.await.unwrap();
assert_status(result, 403);
// The pending slot must be free again for the next sender.
let client = LsHttpClientV2::try_new_without_cert().unwrap();
let result = client
.prepare_upload(
ProtocolType::Http,
"127.0.0.1",
port,
None,
prepare_upload_request(&[file_dto("file-b", "b.bin", 5)]),
None,
CancellationToken::new(),
)
.await;
assert_status(result, 403);
let _ = stop_tx.send(());
}
/// Same as [test_prepare_upload_aborted_by_sender_disconnect], but over TLS
/// with mutual certificates - the transport every real LocalSend transfer
/// uses - and cancelled through the client's cancellation token, the way a
/// sender cancels while waiting for the receiver's decision.
#[tokio::test]
async fn test_prepare_upload_aborted_by_sender_disconnect_tls() {
let _ = tracing_subscriber::fmt().with_test_writer().try_init();
let port = free_port();
let server_key = rcgen::KeyPair::generate().unwrap();
let server_cert = rcgen::CertificateParams::new(vec!["LocalSend User".to_string()])
.unwrap()
.self_signed(&server_key)
.unwrap();
let sender_key = rcgen::KeyPair::generate().unwrap();
let sender_cert = rcgen::CertificateParams::new(vec!["LocalSend User".to_string()])
.unwrap()
.self_signed(&sender_key)
.unwrap();
let (event_tx, mut event_rx) = mpsc::channel::<ServerEventV2>(16);
let (aborted_tx, aborted_rx) = oneshot::channel::<String>();
tokio::spawn(async move {
let mut held_decision = None;
let mut aborted_tx = Some(aborted_tx);
while let Some(event) = event_rx.recv().await {
match event {
ServerEventV2::PrepareUpload { decision_tx, .. } => {
if held_decision.is_none() {
held_decision = Some(decision_tx);
} else {
let _ = decision_tx.send(PrepareUploadDecisionV2::Decline);
}
}
ServerEventV2::PrepareUploadAborted { session_id } => {
if let Some(tx) = aborted_tx.take() {
let _ = tx.send(session_id);
}
}
_ => {}
}
}
});
let (stop_tx, stop_rx) = oneshot::channel::<()>();
start_with_port(
port,
Some(localsend::http::server::TlsConfig {
cert: server_cert.pem(),
private_key: server_key.serialize_pem(),
}),
ClientInfo {
alias: "Test Server".to_string(),
version: "2.1".to_string(),
device_model: Some("Rust".to_string()),
device_type: None,
token: "server-fingerprint".to_string(),
},
None,
Some(ServerConfigV2 {
pin: None,
event_tx,
}),
None,
stop_rx,
)
.await
.expect("Failed to start server");
wait_until_reachable(port).await;
// The sender cancels while the server is still waiting for the
// application's decision.
let client =
LsHttpClientV2::try_new(&sender_key.serialize_pem(), &sender_cert.pem(), None, None)
.unwrap();
let cancel = CancellationToken::new();
tokio::spawn({
let cancel = cancel.clone();
async move {
tokio::time::sleep(Duration::from_millis(300)).await;
cancel.cancel();
}
});
let result = client
.prepare_upload(
ProtocolType::Https,
"127.0.0.1",
port,
None,
prepare_upload_request(&[file_dto("file-a", "a.bin", 5)]),
None,
cancel,
)
.await;
assert!(
matches!(result, Err(ClientError::Cancelled)),
"expected ClientError::Cancelled, got {:?}",
result.err()
);
tokio::time::timeout(Duration::from_secs(3), aborted_rx)
.await
.expect("PrepareUploadAborted was not emitted after the sender disconnected (TLS)")
.unwrap();
// The pending slot must be free again for the next sender.
let client =
LsHttpClientV2::try_new(&sender_key.serialize_pem(), &sender_cert.pem(), None, None)
.unwrap();
let result = client
.prepare_upload(
ProtocolType::Https,
"127.0.0.1",
port,
None,
prepare_upload_request(&[file_dto("file-b", "b.bin", 5)]),
None,
CancellationToken::new(),
)
.await;
assert_status(result, 403);
let _ = stop_tx.send(());
}
#[tokio::test]
async fn test_prepare_upload_declined() {
let server = start_test_server(None, false, None).await;
@@ -823,6 +1187,7 @@ async fn test_prepare_upload_declined() {
None,
prepare_upload_request(&[file.clone()]),
None,
CancellationToken::new(),
)
.await;
assert_status(result, 403);
@@ -836,6 +1201,7 @@ async fn test_prepare_upload_declined() {
None,
prepare_upload_request(&[file]),
None,
CancellationToken::new(),
)
.await;
assert_status(result, 403);
@@ -857,6 +1223,7 @@ async fn test_pin() {
None,
prepare_upload_request(&[file.clone()]),
None,
CancellationToken::new(),
)
.await;
assert_status(result, 401);
@@ -870,6 +1237,7 @@ async fn test_pin() {
None,
prepare_upload_request(&[file.clone()]),
Some("000000"),
CancellationToken::new(),
)
.await;
assert_status(result, 401);
@@ -883,6 +1251,7 @@ async fn test_pin() {
None,
prepare_upload_request(&[file]),
Some("123456"),
CancellationToken::new(),
)
.await
.unwrap();
@@ -904,6 +1273,7 @@ async fn test_pin_too_many_attempts() {
None,
prepare_upload_request(&[file.clone()]),
Some("000000"),
CancellationToken::new(),
)
.await;
assert_status(result, 401);
@@ -918,6 +1288,7 @@ async fn test_pin_too_many_attempts() {
None,
prepare_upload_request(&[file]),
Some("123456"),
CancellationToken::new(),
)
.await;
assert_status(result, 429);
+2
View File
@@ -274,6 +274,7 @@ async fn test_transfer_with_matching_fingerprint() {
None,
prepare_upload_request(&sender, &files),
None,
CancellationToken::new(),
)
.await
.expect("prepare-upload should succeed");
@@ -321,6 +322,7 @@ async fn test_prepare_upload_rejected_on_fingerprint_mismatch() {
None,
prepare_upload_request(&sender, &files),
None,
CancellationToken::new(),
)
.await;
@@ -12,7 +12,7 @@ import 'package:localsend_isolates/rust/frb_generated.dart';
part 'http.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `resolve_file_content`
// These functions are ignored because they are not marked as `pub`: `error_chain`, `resolve_file_content`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `from`
/// Creates an HTTP client.
@@ -46,6 +46,7 @@ abstract class RsHttpClient implements RustOpaqueInterface {
required PrepareUploadRequestDto payload,
String? publicKey,
String? pin,
required RsCancellationToken cancelToken,
});
Future<ResultWithPublicKeyRegisterResponseDto> register({
@@ -53,11 +53,25 @@ abstract class RsHttpServer implements RustOpaqueInterface {
/// transfer on the receiving side.
///
/// Uploads that are already in progress still run to completion, but new
/// upload requests are rejected and a new session can be created.
/// upload requests fail and a new session can be created.
/// No [RsServerEvent::SessionEnd] is emitted: the application initiated
/// the cancellation itself.
Future<void> cancelSession({required String sessionId});
/// Fails the pending [RsServerEvent::WebFileDownload] event, e.g. because
/// the application failed to resolve a source for the file content.
///
/// The download request fails with an error response.
/// Does nothing if the download was already answered.
Future<void> failFileDownload({required String sessionId, required String fileId});
/// Fails the pending [RsServerEvent::FileUpload] event, e.g. because
/// the application failed to prepare a save target for the file.
///
/// The upload request fails with an error response and the file is marked
/// as failed. Does nothing if the upload was already answered.
Future<void> failFileUpload({required String sessionId, required String fileId});
/// Emits server events until the server is stopped.
/// Can only be listened to once.
///
@@ -65,20 +79,6 @@ abstract class RsHttpServer implements RustOpaqueInterface {
/// events are all emitted on the same stream.
Stream<RsServerEvent> listen();
/// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because
/// the application failed to resolve a source for the file content.
///
/// The download request fails with an error response.
/// Does nothing if the download was already answered.
Future<void> rejectFileDownload({required String sessionId, required String fileId});
/// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because
/// the application failed to prepare a save target for the file.
///
/// The upload request fails with an error response and the file is marked
/// as failed. Does nothing if the upload was already answered.
Future<void> rejectFileUpload({required String sessionId, required String fileId});
/// Answers the pending [RsServerEvent::WebFileDownload] event with the source
/// the file content should be read from (either a path or a file descriptor).
///
@@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -2071906741;
int get rustContentHash => 1795427439;
static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig(
stem: 'rust_lib_localsend_app',
@@ -128,6 +128,7 @@ abstract class RustLibApi extends BaseApi {
required PrepareUploadRequestDto payload,
String? publicKey,
String? pin,
required RsCancellationToken cancelToken,
});
Future<ResultWithPublicKeyRegisterResponseDto> crateApiHttpRsHttpClientRegister({
@@ -156,12 +157,12 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiServerRsHttpServerCancelSession({required RsHttpServer that, required String sessionId});
Future<void> crateApiServerRsHttpServerFailFileDownload({required RsHttpServer that, required String sessionId, required String fileId});
Future<void> crateApiServerRsHttpServerFailFileUpload({required RsHttpServer that, required String sessionId, required String fileId});
Stream<RsServerEvent> crateApiServerRsHttpServerListen({required RsHttpServer that});
Future<void> crateApiServerRsHttpServerRejectFileDownload({required RsHttpServer that, required String sessionId, required String fileId});
Future<void> crateApiServerRsHttpServerRejectFileUpload({required RsHttpServer that, required String sessionId, required String fileId});
Future<void> crateApiServerRsHttpServerRespondFileDownload({
required RsHttpServer that,
required String sessionId,
@@ -574,6 +575,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
required PrepareUploadRequestDto payload,
String? publicKey,
String? pin,
required RsCancellationToken cancelToken,
}) {
return handler.executeNormal(
NormalTask(
@@ -586,6 +588,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_box_autoadd_prepare_upload_request_dto(payload, serializer);
sse_encode_opt_String(publicKey, serializer);
sse_encode_opt_String(pin, serializer);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken(cancelToken, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8, port: port_);
},
codec: SseCodec(
@@ -593,7 +596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
decodeErrorData: sse_decode_rs_http_client_error,
),
constMeta: kCrateApiHttpRsHttpClientPrepareUploadConstMeta,
argValues: [that, protocol, ip, port, payload, publicKey, pin],
argValues: [that, protocol, ip, port, payload, publicKey, pin, cancelToken],
apiImpl: this,
),
);
@@ -601,7 +604,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiHttpRsHttpClientPrepareUploadConstMeta => const TaskConstMeta(
debugName: 'RsHttpClient_prepare_upload',
argNames: ['that', 'protocol', 'ip', 'port', 'payload', 'publicKey', 'pin'],
argNames: ['that', 'protocol', 'ip', 'port', 'payload', 'publicKey', 'pin', 'cancelToken'],
);
@override
@@ -739,6 +742,60 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ['that', 'sessionId'],
);
@override
Future<void> crateApiServerRsHttpServerFailFileDownload({required RsHttpServer that, required String sessionId, required String fileId}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
sse_encode_String(sessionId, serializer);
sse_encode_String(fileId, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiServerRsHttpServerFailFileDownloadConstMeta,
argValues: [that, sessionId, fileId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiServerRsHttpServerFailFileDownloadConstMeta => const TaskConstMeta(
debugName: 'RsHttpServer_fail_file_download',
argNames: ['that', 'sessionId', 'fileId'],
);
@override
Future<void> crateApiServerRsHttpServerFailFileUpload({required RsHttpServer that, required String sessionId, required String fileId}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
sse_encode_String(sessionId, serializer);
sse_encode_String(fileId, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiServerRsHttpServerFailFileUploadConstMeta,
argValues: [that, sessionId, fileId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiServerRsHttpServerFailFileUploadConstMeta => const TaskConstMeta(
debugName: 'RsHttpServer_fail_file_upload',
argNames: ['that', 'sessionId', 'fileId'],
);
@override
Stream<RsServerEvent> crateApiServerRsHttpServerListen({required RsHttpServer that}) {
final sink = RustStreamSink<RsServerEvent>();
@@ -749,7 +806,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
sse_encode_StreamSink_rs_server_event_Sse(sink, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12, port: port_);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -769,60 +826,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ['that', 'sink'],
);
@override
Future<void> crateApiServerRsHttpServerRejectFileDownload({required RsHttpServer that, required String sessionId, required String fileId}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
sse_encode_String(sessionId, serializer);
sse_encode_String(fileId, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiServerRsHttpServerRejectFileDownloadConstMeta,
argValues: [that, sessionId, fileId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiServerRsHttpServerRejectFileDownloadConstMeta => const TaskConstMeta(
debugName: 'RsHttpServer_reject_file_download',
argNames: ['that', 'sessionId', 'fileId'],
);
@override
Future<void> crateApiServerRsHttpServerRejectFileUpload({required RsHttpServer that, required String sessionId, required String fileId}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
sse_encode_String(sessionId, serializer);
sse_encode_String(fileId, serializer);
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14, port: port_);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: null,
),
constMeta: kCrateApiServerRsHttpServerRejectFileUploadConstMeta,
argValues: [that, sessionId, fileId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiServerRsHttpServerRejectFileUploadConstMeta => const TaskConstMeta(
debugName: 'RsHttpServer_reject_file_upload',
argNames: ['that', 'sessionId', 'fileId'],
);
@override
Future<void> crateApiServerRsHttpServerRespondFileDownload({
required RsHttpServer that,
@@ -5573,6 +5576,7 @@ class RsHttpClientImpl extends RustOpaque implements RsHttpClient {
required PrepareUploadRequestDto payload,
String? publicKey,
String? pin,
required RsCancellationToken cancelToken,
}) => RustLib.instance.api.crateApiHttpRsHttpClientPrepareUpload(
that: this,
protocol: protocol,
@@ -5581,6 +5585,7 @@ class RsHttpClientImpl extends RustOpaque implements RsHttpClient {
payload: payload,
publicKey: publicKey,
pin: pin,
cancelToken: cancelToken,
);
Future<ResultWithPublicKeyRegisterResponseDto> register({
@@ -5638,12 +5643,28 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer {
/// transfer on the receiving side.
///
/// Uploads that are already in progress still run to completion, but new
/// upload requests are rejected and a new session can be created.
/// upload requests fail and a new session can be created.
/// No [RsServerEvent::SessionEnd] is emitted: the application initiated
/// the cancellation itself.
Future<void> cancelSession({required String sessionId}) =>
RustLib.instance.api.crateApiServerRsHttpServerCancelSession(that: this, sessionId: sessionId);
/// Fails the pending [RsServerEvent::WebFileDownload] event, e.g. because
/// the application failed to resolve a source for the file content.
///
/// The download request fails with an error response.
/// Does nothing if the download was already answered.
Future<void> failFileDownload({required String sessionId, required String fileId}) =>
RustLib.instance.api.crateApiServerRsHttpServerFailFileDownload(that: this, sessionId: sessionId, fileId: fileId);
/// Fails the pending [RsServerEvent::FileUpload] event, e.g. because
/// the application failed to prepare a save target for the file.
///
/// The upload request fails with an error response and the file is marked
/// as failed. Does nothing if the upload was already answered.
Future<void> failFileUpload({required String sessionId, required String fileId}) =>
RustLib.instance.api.crateApiServerRsHttpServerFailFileUpload(that: this, sessionId: sessionId, fileId: fileId);
/// Emits server events until the server is stopped.
/// Can only be listened to once.
///
@@ -5653,22 +5674,6 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer {
that: this,
);
/// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because
/// the application failed to resolve a source for the file content.
///
/// The download request fails with an error response.
/// Does nothing if the download was already answered.
Future<void> rejectFileDownload({required String sessionId, required String fileId}) =>
RustLib.instance.api.crateApiServerRsHttpServerRejectFileDownload(that: this, sessionId: sessionId, fileId: fileId);
/// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because
/// the application failed to prepare a save target for the file.
///
/// The upload request fails with an error response and the file is marked
/// as failed. Does nothing if the upload was already answered.
Future<void> rejectFileUpload({required String sessionId, required String fileId}) =>
RustLib.instance.api.crateApiServerRsHttpServerRejectFileUpload(that: this, sessionId: sessionId, fileId: fileId);
/// Answers the pending [RsServerEvent::WebFileDownload] event with the source
/// the file content should be read from (either a path or a file descriptor).
///
@@ -144,15 +144,15 @@ class HttpServerFileDownloadTargetTask implements BaseHttpServerTask {
});
}
/// Rejects a pending [HttpServerWebFileDownloadEvent], e.g. because no source
/// Fails a pending [HttpServerWebFileDownloadEvent], e.g. because no source
/// for the file content could be resolved. The download request fails with an
/// error response. Does nothing if the download was already answered with a
/// [HttpServerFileDownloadTargetTask].
class HttpServerRejectFileDownloadTask implements BaseHttpServerTask {
class HttpServerFailFileDownloadTask implements BaseHttpServerTask {
final String sessionId;
final String fileId;
HttpServerRejectFileDownloadTask({
HttpServerFailFileDownloadTask({
required this.sessionId,
required this.fileId,
});
@@ -575,12 +575,12 @@ Future<void> setupHttpServerIsolate(
fileDescriptor: targetTask.fileDescriptor,
);
return;
case HttpServerRejectFileDownloadTask rejectTask:
case HttpServerFailFileDownloadTask failTask:
await ref
.read(httpServerProvider)
.rejectFileDownload(
sessionId: rejectTask.sessionId,
fileId: rejectTask.fileId,
.failFileDownload(
sessionId: failTask.sessionId,
fileId: failTask.fileId,
);
return;
}
@@ -642,12 +642,12 @@ Future<void> _handleFileUpload({
} catch (e, st) {
_logger.severe('Failed to prepare save target', e, st);
// The Rust server is still waiting for the target; rejecting fails the
// The Rust server is still waiting for the target; failing it ends the
// sender's request which would otherwise hang forever.
try {
await ref.read(httpServerProvider).rejectFileUpload(sessionId: sessionId, fileId: fileId);
await ref.read(httpServerProvider).failFileUpload(sessionId: sessionId, fileId: fileId);
} catch (e) {
_logger.warning('Failed to reject file upload', e);
_logger.warning('Could not fail the pending file upload', e);
}
emitFailed(e);
@@ -391,16 +391,16 @@ class IsolateHttpServerFileDownloadTargetAction extends ReduxAction<IsolateContr
}
}
/// Rejects a pending [HttpServerWebFileDownloadEvent], e.g. because no source
/// Fails a pending [HttpServerWebFileDownloadEvent], e.g. because no source
/// for the file content could be resolved. The web client receives an error
/// response for this file.
/// Does nothing if the download was already answered with a
/// [IsolateHttpServerFileDownloadTargetAction].
class IsolateHttpServerRejectFileDownloadAction extends ReduxAction<IsolateController, ParentIsolateState> {
class IsolateHttpServerFailFileDownloadAction extends ReduxAction<IsolateController, ParentIsolateState> {
final String sessionId;
final String fileId;
IsolateHttpServerRejectFileDownloadAction({
IsolateHttpServerFailFileDownloadAction({
required this.sessionId,
required this.fileId,
});
@@ -416,7 +416,7 @@ class IsolateHttpServerRejectFileDownloadAction extends ReduxAction<IsolateContr
SendToIsolateData(
syncState: null,
data: IsolateTask(
data: HttpServerRejectFileDownloadTask(
data: HttpServerFailFileDownloadTask(
sessionId: sessionId,
fileId: fileId,
),
@@ -73,16 +73,16 @@ class HttpServerService {
);
}
/// Rejects a pending file upload, e.g. because no save target could be
/// Fails a pending file upload, e.g. because no save target could be
/// prepared. The upload request fails with an error response and the file is
/// marked as failed; the session itself continues.
/// Does nothing if the upload was already answered via [respondFileUpload].
Future<void> rejectFileUpload({required String sessionId, required String fileId}) async {
await _requireServer().rejectFileUpload(sessionId: sessionId, fileId: fileId);
Future<void> failFileUpload({required String sessionId, required String fileId}) async {
await _requireServer().failFileUpload(sessionId: sessionId, fileId: fileId);
}
/// Cancels the active upload session. Uploads that are already in progress
/// still run to completion, but new upload requests are rejected and a new
/// still run to completion, but new upload requests fail and a new
/// session can be created. No session-end event is emitted.
Future<void> cancelSession({required String sessionId}) async {
await _requireServer().cancelSession(sessionId: sessionId);
@@ -110,11 +110,11 @@ class HttpServerService {
);
}
/// Rejects a pending web file download, e.g. because no content source could
/// Fails a pending web file download, e.g. because no content source could
/// be resolved. The download request fails with an error response.
/// Does nothing if the download was already answered via [respondFileDownload].
Future<void> rejectFileDownload({required String sessionId, required String fileId}) async {
await _requireServer().rejectFileDownload(sessionId: sessionId, fileId: fileId);
Future<void> failFileDownload({required String sessionId, required String fileId}) async {
await _requireServer().failFileDownload(sessionId: sessionId, fileId: fileId);
}
/// Stops the server. The event stream returned by [start] will end.
@@ -67,10 +67,19 @@ impl RsHttpClient {
payload: PrepareUploadRequestDto,
public_key: Option<String>,
pin: Option<String>,
cancel_token: &RsCancellationToken,
) -> Result<PrepareUploadResult, RsHttpClientError> {
let response = self
.inner
.prepare_upload(protocol, ip, port, public_key, payload, pin.as_deref())
.prepare_upload(
protocol,
ip,
port,
public_key,
payload,
pin.as_deref(),
cancel_token.inner.clone(),
)
.await
.map_err(RsHttpClientError::from)?;
@@ -208,7 +217,7 @@ impl From<ClientError> for RsHttpClientError {
/// Renders an error together with everything that caused it.
///
/// [`reqwest::Error`] alone only says "error sending request for url (...)".
pub fn error_chain(e: &dyn std::error::Error) -> String {
pub(crate) fn error_chain(e: &dyn std::error::Error) -> String {
use std::fmt::Write;
let mut message = e.to_string();
@@ -443,12 +443,12 @@ impl RsHttpServer {
}
}
/// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because
/// Fails the pending [RsServerEvent::FileUpload] event, e.g. because
/// the application failed to prepare a save target for the file.
///
/// The upload request fails with an error response and the file is marked
/// as failed. Does nothing if the upload was already answered.
pub async fn reject_file_upload(&self, session_id: String, file_id: String) {
pub async fn fail_file_upload(&self, session_id: String, file_id: String) {
// Dropping the responder fails the request waiting for the target.
self.pending_uploads
.lock()
@@ -509,12 +509,12 @@ impl RsHttpServer {
Ok(())
}
/// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because
/// Fails the pending [RsServerEvent::WebFileDownload] event, e.g. because
/// the application failed to resolve a source for the file content.
///
/// The download request fails with an error response.
/// Does nothing if the download was already answered.
pub async fn reject_file_download(&self, session_id: String, file_id: String) {
pub async fn fail_file_download(&self, session_id: String, file_id: String) {
// Dropping the responder fails the request waiting for the content.
self.pending_downloads
.lock()
@@ -526,7 +526,7 @@ impl RsHttpServer {
/// transfer on the receiving side.
///
/// Uploads that are already in progress still run to completion, but new
/// upload requests are rejected and a new session can be created.
/// upload requests fail and a new session can be created.
/// No [RsServerEvent::SessionEnd] is emitted: the application initiated
/// the cancellation itself.
pub async fn cancel_session(&self, session_id: String) {
@@ -44,7 +44,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2071906741;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1795427439;
// Section: executor
@@ -506,16 +506,27 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl(
<crate::api::model::PrepareUploadRequestDto>::sse_decode(&mut deserializer);
let api_public_key = <Option<String>>::sse_decode(&mut deserializer);
let api_pin = <Option<String>>::sse_decode(&mut deserializer);
let api_cancel_token = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsCancellationToken>,
>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::api::http::RsHttpClientError>(
(move || async move {
let mut api_that_guard = None;
let mut api_cancel_token_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
vec![
flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
),
flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_cancel_token,
1,
false,
),
],
);
for i in decode_indices_ {
match i {
@@ -523,10 +534,15 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl(
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
1 => {
api_cancel_token_guard =
Some(api_cancel_token.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let api_cancel_token_guard = api_cancel_token_guard.unwrap();
let output_ok = crate::api::http::RsHttpClient::prepare_upload(
&*api_that_guard,
api_protocol,
@@ -535,6 +551,7 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl(
api_payload,
api_public_key,
api_pin,
&*api_cancel_token_guard,
)
.await?;
Ok(output_ok)
@@ -775,6 +792,134 @@ fn wire__crate__api__server__RsHttpServer_cancel_session_impl(
},
)
}
fn wire__crate__api__server__RsHttpServer_fail_file_download_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "RsHttpServer_fail_file_download",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_that = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServer>,
>>::sse_decode(&mut deserializer);
let api_session_id = <String>::sse_decode(&mut deserializer);
let api_file_id = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let output_ok = Result::<_, ()>::Ok({
crate::api::server::RsHttpServer::fail_file_download(
&*api_that_guard,
api_session_id,
api_file_id,
)
.await;
})?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__server__RsHttpServer_fail_file_upload_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "RsHttpServer_fail_file_upload",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_that = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServer>,
>>::sse_decode(&mut deserializer);
let api_session_id = <String>::sse_decode(&mut deserializer);
let api_file_id = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let output_ok = Result::<_, ()>::Ok({
crate::api::server::RsHttpServer::fail_file_upload(
&*api_that_guard,
api_session_id,
api_file_id,
)
.await;
})?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__server__RsHttpServer_listen_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -837,134 +982,6 @@ fn wire__crate__api__server__RsHttpServer_listen_impl(
},
)
}
fn wire__crate__api__server__RsHttpServer_reject_file_download_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "RsHttpServer_reject_file_download",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_that = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServer>,
>>::sse_decode(&mut deserializer);
let api_session_id = <String>::sse_decode(&mut deserializer);
let api_file_id = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let output_ok = Result::<_, ()>::Ok({
crate::api::server::RsHttpServer::reject_file_download(
&*api_that_guard,
api_session_id,
api_file_id,
)
.await;
})?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__server__RsHttpServer_reject_file_upload_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "RsHttpServer_reject_file_upload",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_that = <RustOpaqueMoi<
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServer>,
>>::sse_decode(&mut deserializer);
let api_session_id = <String>::sse_decode(&mut deserializer);
let api_file_id = <String>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, ()>(
(move || async move {
let mut api_that_guard = None;
let decode_indices_ =
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
&api_that, 0, false,
)],
);
for i in decode_indices_ {
match i {
0 => {
api_that_guard =
Some(api_that.lockable_decode_async_ref().await)
}
_ => unreachable!(),
}
}
let api_that_guard = api_that_guard.unwrap();
let output_ok = Result::<_, ()>::Ok({
crate::api::server::RsHttpServer::reject_file_upload(
&*api_that_guard,
api_session_id,
api_file_id,
)
.await;
})?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__server__RsHttpServer_respond_file_download_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -4460,19 +4477,19 @@ fn pde_ffi_dispatcher_primary_impl(
rust_vec_len,
data_len,
),
12 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__server__RsHttpServer_reject_file_download_impl(
12 => wire__crate__api__server__RsHttpServer_fail_file_download_impl(
port,
ptr,
rust_vec_len,
data_len,
),
14 => wire__crate__api__server__RsHttpServer_reject_file_upload_impl(
13 => wire__crate__api__server__RsHttpServer_fail_file_upload_impl(
port,
ptr,
rust_vec_len,
data_len,
),
14 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__server__RsHttpServer_respond_file_download_impl(
port,
ptr,