mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: implement CLI
This commit is contained in:
@@ -77,9 +77,16 @@ jobs:
|
|||||||
echo "Inno Setup version is $VERSION"
|
echo "Inno Setup version is $VERSION"
|
||||||
echo "inno_version=$VERSION" >> $GITHUB_ENV
|
echo "inno_version=$VERSION" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Compare pubspec and Inno Setup versions
|
- name: Extract version from CLI Cargo.toml
|
||||||
|
id: cli_version
|
||||||
run: |
|
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!"
|
echo "Version mismatch detected!"
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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/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. |
|
| `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`. |
|
| `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). |
|
| `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.
|
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
|
## 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").
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
"'": '''
|
|
||||||
};
|
|
||||||
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();
|
|
||||||
@@ -42,28 +42,11 @@ class $AssetsImgGen {
|
|||||||
List<dynamic> get values => [logo128, logo256, logo32Black, logo32White, logo32, logo512White, logo512, logo];
|
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 {
|
class Assets {
|
||||||
const Assets._();
|
const Assets._();
|
||||||
|
|
||||||
static const String changelog = 'assets/CHANGELOG.md';
|
static const String changelog = 'assets/CHANGELOG.md';
|
||||||
static const $AssetsImgGen img = $AssetsImgGen();
|
static const $AssetsImgGen img = $AssetsImgGen();
|
||||||
static const $AssetsWebGen web = $AssetsWebGen();
|
|
||||||
|
|
||||||
/// List of all assets
|
/// List of all assets
|
||||||
static List<String> get values => [changelog];
|
static List<String> get values => [changelog];
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
|||||||
/// Session ID -> Cancel token
|
/// Session ID -> Cancel token
|
||||||
final _hashCancelTokens = <String, rust_cancel.RsCancellationToken>{};
|
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
|
@override
|
||||||
Map<String, SendSessionState> init() {
|
Map<String, SendSessionState> init() {
|
||||||
return {};
|
return {};
|
||||||
@@ -195,92 +201,99 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
|||||||
bool invalidPin;
|
bool invalidPin;
|
||||||
bool pinFirstAttempt = true;
|
bool pinFirstAttempt = true;
|
||||||
String? pin;
|
String? pin;
|
||||||
do {
|
final prepareUploadCancelToken = rust_cancel.createCancellationToken();
|
||||||
invalidPin = false;
|
_prepareUploadCancelTokens[sessionId] = prepareUploadCancelToken;
|
||||||
try {
|
try {
|
||||||
response = await client.prepareUpload(
|
do {
|
||||||
protocol: target.getProtocolType(),
|
invalidPin = false;
|
||||||
ip: target.ip!,
|
try {
|
||||||
port: target.port,
|
response = await client.prepareUpload(
|
||||||
payload: requestDto,
|
protocol: target.getProtocolType(),
|
||||||
// The peer is already verified during the TLS handshake by the
|
ip: target.ip!,
|
||||||
// fingerprint the client is pinned to.
|
port: target.port,
|
||||||
publicKey: null,
|
payload: requestDto,
|
||||||
pin: pin,
|
// The peer is already verified during the TLS handshake by the
|
||||||
);
|
// fingerprint the client is pinned to.
|
||||||
} on rust_http.RsHttpClientError_StatusCode catch (e) {
|
publicKey: null,
|
||||||
switch (e.status) {
|
pin: pin,
|
||||||
case 401:
|
cancelToken: prepareUploadCancelToken,
|
||||||
invalidPin = true;
|
);
|
||||||
|
} on rust_http.RsHttpClientError_StatusCode catch (e) {
|
||||||
|
switch (e.status) {
|
||||||
|
case 401:
|
||||||
|
invalidPin = true;
|
||||||
|
|
||||||
// wait until animation is finished
|
// wait until animation is finished
|
||||||
await sleepAsync(500);
|
await sleepAsync(500);
|
||||||
|
|
||||||
pin = await showDialog<String>(
|
pin = await showDialog<String>(
|
||||||
context: Routerino.context, // ignore: use_build_context_synchronously
|
context: Routerino.context, // ignore: use_build_context_synchronously
|
||||||
builder: (_) => PinDialog(
|
builder: (_) => PinDialog(
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
showInvalidPin: !pinFirstAttempt,
|
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(
|
state = state.updateSession(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
state: (s) => s?.copyWith(
|
state: (s) => s?.copyWith(
|
||||||
status: SessionStatus.canceledBySender,
|
status: SessionStatus.declined,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
case 409:
|
||||||
break;
|
state = state.updateSession(
|
||||||
case 403:
|
sessionId: sessionId,
|
||||||
state = state.updateSession(
|
state: (s) => s?.copyWith(
|
||||||
sessionId: sessionId,
|
status: SessionStatus.recipientBusy,
|
||||||
state: (s) => s?.copyWith(
|
),
|
||||||
status: SessionStatus.declined,
|
);
|
||||||
),
|
return;
|
||||||
);
|
case 429:
|
||||||
return;
|
state = state.updateSession(
|
||||||
case 409:
|
sessionId: sessionId,
|
||||||
state = state.updateSession(
|
state: (s) => s?.copyWith(
|
||||||
sessionId: sessionId,
|
status: SessionStatus.tooManyAttempts,
|
||||||
state: (s) => s?.copyWith(
|
),
|
||||||
status: SessionStatus.recipientBusy,
|
);
|
||||||
),
|
return;
|
||||||
);
|
default:
|
||||||
return;
|
state = state.updateSession(
|
||||||
case 429:
|
sessionId: sessionId,
|
||||||
state = state.updateSession(
|
state: (s) => s?.copyWith(
|
||||||
sessionId: sessionId,
|
status: SessionStatus.finishedWithErrors,
|
||||||
state: (s) => s?.copyWith(
|
errorMessage: e.humanErrorMessage,
|
||||||
status: SessionStatus.tooManyAttempts,
|
),
|
||||||
),
|
);
|
||||||
);
|
return;
|
||||||
return;
|
}
|
||||||
default:
|
} catch (e) {
|
||||||
state = state.updateSession(
|
state = state.updateSession(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
state: (s) => s?.copyWith(
|
state: (s) => s?.copyWith(
|
||||||
status: SessionStatus.finishedWithErrors,
|
status: SessionStatus.finishedWithErrors,
|
||||||
errorMessage: e.humanErrorMessage,
|
errorMessage: e.humanErrorMessage,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} while (invalidPin);
|
||||||
state = state.updateSession(
|
} finally {
|
||||||
sessionId: sessionId,
|
_prepareUploadCancelTokens.remove(sessionId);
|
||||||
state: (s) => s?.copyWith(
|
}
|
||||||
status: SessionStatus.finishedWithErrors,
|
|
||||||
errorMessage: e.humanErrorMessage,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} while (invalidPin);
|
|
||||||
|
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
return;
|
return;
|
||||||
@@ -672,6 +685,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
|||||||
|
|
||||||
void _cancelRunningRequests(SendSessionState state) {
|
void _cancelRunningRequests(SendSessionState state) {
|
||||||
_hashCancelTokens.remove(state.sessionId)?.cancel();
|
_hashCancelTokens.remove(state.sessionId)?.cancel();
|
||||||
|
_prepareUploadCancelTokens.remove(state.sessionId)?.cancel();
|
||||||
|
|
||||||
for (final task in state.sendingTasks ?? <SendingTask>[]) {
|
for (final task in state.sendingTasks ?? <SendingTask>[]) {
|
||||||
ref
|
ref
|
||||||
@@ -692,6 +706,7 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
|||||||
}
|
}
|
||||||
TransferNotification.stop(sessionId);
|
TransferNotification.stop(sessionId);
|
||||||
_hashCancelTokens.remove(sessionId)?.cancel();
|
_hashCancelTokens.remove(sessionId)?.cancel();
|
||||||
|
_prepareUploadCancelTokens.remove(sessionId)?.cancel();
|
||||||
state = state.removeSession(ref, sessionId);
|
state = state.removeSession(ref, sessionId);
|
||||||
if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) {
|
if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) {
|
||||||
// clear selected files
|
// clear selected files
|
||||||
@@ -707,6 +722,10 @@ class SendNotifier extends Notifier<Map<String, SendSessionState>> {
|
|||||||
cancelToken.cancel();
|
cancelToken.cancel();
|
||||||
}
|
}
|
||||||
_hashCancelTokens.clear();
|
_hashCancelTokens.clear();
|
||||||
|
for (final cancelToken in _prepareUploadCancelTokens.values) {
|
||||||
|
cancelToken.cancel();
|
||||||
|
}
|
||||||
|
_prepareUploadCancelTokens.clear();
|
||||||
state = {};
|
state = {};
|
||||||
ref.notifier(progressProvider).removeAllSessions();
|
ref.notifier(progressProvider).removeAllSessions();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ class ReceiveController {
|
|||||||
final receiveState = server.getStateOrNull()?.session;
|
final receiveState = server.getStateOrNull()?.session;
|
||||||
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
|
const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors};
|
||||||
if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) {
|
if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) {
|
||||||
_logger.warning('Rejecting upload of file ${event.fileId}: no matching active session');
|
_logger.warning('Failing upload of file ${event.fileId}: no matching active session');
|
||||||
// Reject the upload (and any further ones) by cancelling the session on the Rust side.
|
// Fail the upload (and any further ones) by cancelling the session on the Rust side.
|
||||||
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId));
|
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -628,7 +628,7 @@ class ReceiveController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// In addition to [closeSession], this method also
|
/// 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
|
/// - notifies the sender that the session has been canceled
|
||||||
void cancelSession() async {
|
void cancelSession() async {
|
||||||
final session = server.getStateOrNull()?.session;
|
final session = server.getStateOrNull()?.session;
|
||||||
@@ -637,7 +637,7 @@ class ReceiveController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// reject further uploads
|
// fail further uploads
|
||||||
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: session.sessionId));
|
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: session.sessionId));
|
||||||
|
|
||||||
// notify sender
|
// notify sender
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class SendController {
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
_logger.severe('Failed to resolve source for web send file ${event.fileId}', 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.
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/img/
|
- assets/img/
|
||||||
- assets/web/
|
|
||||||
- assets/CHANGELOG.md
|
- assets/CHANGELOG.md
|
||||||
|
|
||||||
flutter_gen:
|
flutter_gen:
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import 'dart:async' as _i4;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart' as _i8;
|
import 'package:flutter/material.dart' as _i8;
|
||||||
import 'package:localsend_app/gen/strings.g.dart' as _i10;
|
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/color_mode.dart' as _i9;
|
||||||
import 'package:localsend_app/model/persistence/favorite_device.dart' as _i6;
|
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/persistence/receive_history_entry.dart' as _i5;
|
||||||
import 'package:localsend_app/model/send_mode.dart' as _i11;
|
import 'package:localsend_app/model/send_mode.dart' as _i11;
|
||||||
import 'package:localsend_app/provider/persistence_provider.dart' as _i3;
|
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/mockito.dart' as _i1;
|
||||||
import 'package:mockito/src/dummies.dart' as _i7;
|
import 'package:mockito/src/dummies.dart' as _i7;
|
||||||
import 'package:shared_preferences/shared_preferences.dart' as _i13;
|
import 'package:shared_preferences/shared_preferences.dart' as _i13;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
Generated
+4768
File diff suppressed because it is too large
Load Diff
+27
-2
@@ -1,6 +1,31 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cli"
|
name = "localsend-cli"
|
||||||
version = "0.1.0"
|
version = "1.17.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "localsend-cli"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,3 +1,44 @@
|
|||||||
fn main() {
|
mod app;
|
||||||
println!("Hello, world!");
|
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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,17 +109,18 @@ impl LsHttpClient {
|
|||||||
public_key: Option<String>,
|
public_key: Option<String>,
|
||||||
payload: http::dto::PrepareUploadRequestDto,
|
payload: http::dto::PrepareUploadRequestDto,
|
||||||
pin: Option<&str>,
|
pin: Option<&str>,
|
||||||
|
cancel: tokio_util::sync::CancellationToken,
|
||||||
) -> Result<http::dto::PrepareUploadResult, ClientError> {
|
) -> Result<http::dto::PrepareUploadResult, ClientError> {
|
||||||
match self {
|
match self {
|
||||||
LsHttpClient::V2(client) => {
|
LsHttpClient::V2(client) => {
|
||||||
let result = 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?;
|
.await?;
|
||||||
Ok(result.into())
|
Ok(result.into())
|
||||||
}
|
}
|
||||||
LsHttpClient::V3(client) => {
|
LsHttpClient::V3(client) => {
|
||||||
client
|
client
|
||||||
.prepare_upload(protocol, ip, port, public_key, payload)
|
.prepare_upload(protocol, ip, port, public_key, payload, cancel)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ impl LsHttpClientV2 {
|
|||||||
/// * `public_key` - Expected public key for verification (HTTPS only)
|
/// * `public_key` - Expected public key for verification (HTTPS only)
|
||||||
/// * `payload` - Upload request with device info and file metadata
|
/// * `payload` - Upload request with device info and file metadata
|
||||||
/// * `pin` - Optional PIN if required by receiver
|
/// * `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
|
/// # Returns
|
||||||
/// Session ID and accepted file tokens, or an error.
|
/// Session ID and accepted file tokens, or an error.
|
||||||
@@ -139,6 +143,7 @@ impl LsHttpClientV2 {
|
|||||||
public_key: Option<String>,
|
public_key: Option<String>,
|
||||||
payload: PrepareUploadRequestDtoV2,
|
payload: PrepareUploadRequestDtoV2,
|
||||||
pin: Option<&str>,
|
pin: Option<&str>,
|
||||||
|
cancel: CancellationToken,
|
||||||
) -> Result<PrepareUploadResultV2, ClientError> {
|
) -> Result<PrepareUploadResultV2, ClientError> {
|
||||||
let pin_params: &[(&'static str, &str)] = match &pin {
|
let pin_params: &[(&'static str, &str)] = match &pin {
|
||||||
Some(pin) => &[("pin", pin)],
|
Some(pin) => &[("pin", pin)],
|
||||||
@@ -154,13 +159,17 @@ impl LsHttpClientV2 {
|
|||||||
}
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let res = self
|
let send = self
|
||||||
.client
|
.client
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.body(serde_json::to_string(&payload)?)
|
.body(serde_json::to_string(&payload)?)
|
||||||
.send()
|
.send();
|
||||||
.await?;
|
|
||||||
|
let res = tokio::select! {
|
||||||
|
res = send => res?,
|
||||||
|
_ = cancel.cancelled() => return Err(ClientError::Cancelled),
|
||||||
|
};
|
||||||
|
|
||||||
if protocol == ProtocolType::Https {
|
if protocol == ProtocolType::Https {
|
||||||
super::verify_cert_from_res(&res, public_key)?;
|
super::verify_cert_from_res(&res, public_key)?;
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ impl LsHttpClientV3 {
|
|||||||
Ok(ResultWithPublicKey { public_key, body })
|
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(
|
pub async fn prepare_upload(
|
||||||
&self,
|
&self,
|
||||||
protocol: ProtocolType,
|
protocol: ProtocolType,
|
||||||
@@ -140,8 +143,9 @@ impl LsHttpClientV3 {
|
|||||||
port: u16,
|
port: u16,
|
||||||
public_key: Option<String>,
|
public_key: Option<String>,
|
||||||
payload: http::dto::PrepareUploadRequestDto,
|
payload: http::dto::PrepareUploadRequestDto,
|
||||||
|
cancel: CancellationToken,
|
||||||
) -> Result<http::dto::PrepareUploadResult, ClientError> {
|
) -> Result<http::dto::PrepareUploadResult, ClientError> {
|
||||||
let res = self
|
let send = self
|
||||||
.client
|
.client
|
||||||
.post(
|
.post(
|
||||||
TargetUrl {
|
TargetUrl {
|
||||||
@@ -155,8 +159,12 @@ impl LsHttpClientV3 {
|
|||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
.body(serde_json::to_string(&payload)?)
|
.body(serde_json::to_string(&payload)?)
|
||||||
.send()
|
.send();
|
||||||
.await?;
|
|
||||||
|
let res = tokio::select! {
|
||||||
|
res = send => res?,
|
||||||
|
_ = cancel.cancelled() => return Err(ClientError::Cancelled),
|
||||||
|
};
|
||||||
|
|
||||||
if protocol == ProtocolType::Https {
|
if protocol == ProtocolType::Https {
|
||||||
super::verify_cert_from_res(&res, public_key)?;
|
super::verify_cert_from_res(&res, public_key)?;
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
use crate::http::server::PeerIp;
|
use crate::http::server::PeerIp;
|
||||||
use crate::model::transfer::FileDto;
|
use crate::model::transfer::FileDto;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
/// State of the single v2 upload session slot.
|
/// State of the single v2 upload session slot.
|
||||||
pub(crate) enum SessionStateV2 {
|
pub(crate) enum SessionStateV2 {
|
||||||
/// A prepare-upload request is waiting for the application's decision.
|
/// A prepare-upload request is waiting for the application's decision.
|
||||||
Pending,
|
Pending(PendingSessionV2),
|
||||||
|
|
||||||
/// An accepted upload session.
|
/// An accepted upload session.
|
||||||
Active(UploadSessionV2),
|
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) struct UploadSessionV2 {
|
||||||
pub(crate) session_id: String,
|
pub(crate) session_id: String,
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ impl ServerHandle {
|
|||||||
/// e.g. because the user aborted the transfer on the receiving side.
|
/// e.g. because the user aborted the transfer on the receiving side.
|
||||||
///
|
///
|
||||||
/// Uploads that are already in progress still run to completion, but new
|
/// 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
|
/// No [ServerEventV2::SessionEnd] is emitted: the application initiated
|
||||||
/// the cancellation itself.
|
/// the cancellation itself.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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::response::{empty_body, BoxedBody, JsonResponse};
|
||||||
use crate::http::server::common::save::{FileUploadTarget, SaveResult};
|
use crate::http::server::common::save::{FileUploadTarget, SaveResult};
|
||||||
use crate::http::server::common::session::{
|
use crate::http::server::common::session::{
|
||||||
FileStatusV2, SessionFileV2, SessionStateV2, UploadSessionV2,
|
FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2,
|
||||||
};
|
};
|
||||||
use crate::http::server::PeerIp;
|
use crate::http::server::PeerIp;
|
||||||
use crate::http::server::{common, AppState, RequestClientInfo, V2State};
|
use crate::http::server::{common, AppState, RequestClientInfo, V2State};
|
||||||
@@ -19,6 +19,7 @@ use hyper::{Request, Response, StatusCode};
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Events emitted by the v2 HTTP server that must be handled by the application.
|
/// 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()));
|
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.
|
// Claim the single session slot.
|
||||||
{
|
{
|
||||||
let mut slot = v2.session.lock().await;
|
let mut slot = v2.session.lock().await;
|
||||||
@@ -238,11 +242,13 @@ pub(crate) async fn prepare_upload(
|
|||||||
"Blocked by another session".to_string(),
|
"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.
|
// 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());
|
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));
|
return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||||
}
|
}
|
||||||
|
|
||||||
let decision = decision_rx
|
// The sender may cancel the request while the application is deciding.
|
||||||
.await
|
// Returning with the guard still armed frees the slot and emits
|
||||||
.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?;
|
// [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 {
|
let accepted_ids = match decision {
|
||||||
PrepareUploadDecisionV2::Decline => {
|
PrepareUploadDecisionV2::Decline => {
|
||||||
@@ -408,8 +425,36 @@ pub(crate) async fn cancel(
|
|||||||
) -> Result<Response<BoxedBody>, AppError> {
|
) -> Result<Response<BoxedBody>, AppError> {
|
||||||
let v2 = require_v2(&state)?;
|
let v2 = require_v2(&state)?;
|
||||||
let query = parse_query(req.uri().query());
|
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 cancelled = {
|
||||||
let mut slot = v2.session.lock().await;
|
let mut slot = v2.session.lock().await;
|
||||||
match slot.as_ref() {
|
match slot.as_ref() {
|
||||||
@@ -515,7 +560,7 @@ impl Drop for PendingSessionGuard {
|
|||||||
|
|
||||||
async fn clear_pending_session(v2: &V2State) {
|
async fn clear_pending_session(v2: &V2State) {
|
||||||
let mut slot = v2.session.lock().await;
|
let mut slot = v2.session.lock().await;
|
||||||
if matches!(*slot, Some(SessionStateV2::Pending)) {
|
if matches!(*slot, Some(SessionStateV2::Pending(_))) {
|
||||||
*slot = None;
|
*slot = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ async fn test_full_upload_flow() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
|
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -386,6 +387,7 @@ async fn test_upload_with_matching_sha256() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -423,6 +425,7 @@ async fn test_upload_with_mismatched_sha256() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -459,6 +462,7 @@ async fn test_upload_retry_after_mismatched_sha256() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -522,6 +526,7 @@ async fn test_upload_retry_reuses_the_same_path() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -583,6 +588,7 @@ async fn test_upload_mismatched_sha256_attempts_exhausted() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -639,6 +645,7 @@ async fn test_upload_saved_to_path_by_server() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
|
prepare_upload_request(&[file_a.clone(), file_b.clone()]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -699,6 +706,7 @@ async fn test_upload_with_invalid_token() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -759,6 +767,7 @@ async fn test_second_session_blocked_and_cancel() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file.clone()]),
|
prepare_upload_request(&[file.clone()]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -774,6 +783,7 @@ async fn test_second_session_blocked_and_cancel() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file.clone()]),
|
prepare_upload_request(&[file.clone()]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 409);
|
assert_status(result, 409);
|
||||||
@@ -804,11 +814,365 @@ async fn test_second_session_blocked_and_cancel() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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]
|
#[tokio::test]
|
||||||
async fn test_prepare_upload_declined() {
|
async fn test_prepare_upload_declined() {
|
||||||
let server = start_test_server(None, false, None).await;
|
let server = start_test_server(None, false, None).await;
|
||||||
@@ -823,6 +1187,7 @@ async fn test_prepare_upload_declined() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file.clone()]),
|
prepare_upload_request(&[file.clone()]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 403);
|
assert_status(result, 403);
|
||||||
@@ -836,6 +1201,7 @@ async fn test_prepare_upload_declined() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 403);
|
assert_status(result, 403);
|
||||||
@@ -857,6 +1223,7 @@ async fn test_pin() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file.clone()]),
|
prepare_upload_request(&[file.clone()]),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 401);
|
assert_status(result, 401);
|
||||||
@@ -870,6 +1237,7 @@ async fn test_pin() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file.clone()]),
|
prepare_upload_request(&[file.clone()]),
|
||||||
Some("000000"),
|
Some("000000"),
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 401);
|
assert_status(result, 401);
|
||||||
@@ -883,6 +1251,7 @@ async fn test_pin() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
Some("123456"),
|
Some("123456"),
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -904,6 +1273,7 @@ async fn test_pin_too_many_attempts() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file.clone()]),
|
prepare_upload_request(&[file.clone()]),
|
||||||
Some("000000"),
|
Some("000000"),
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 401);
|
assert_status(result, 401);
|
||||||
@@ -918,6 +1288,7 @@ async fn test_pin_too_many_attempts() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&[file]),
|
prepare_upload_request(&[file]),
|
||||||
Some("123456"),
|
Some("123456"),
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_status(result, 429);
|
assert_status(result, 429);
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ async fn test_transfer_with_matching_fingerprint() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&sender, &files),
|
prepare_upload_request(&sender, &files),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("prepare-upload should succeed");
|
.expect("prepare-upload should succeed");
|
||||||
@@ -321,6 +322,7 @@ async fn test_prepare_upload_rejected_on_fingerprint_mismatch() {
|
|||||||
None,
|
None,
|
||||||
prepare_upload_request(&sender, &files),
|
prepare_upload_request(&sender, &files),
|
||||||
None,
|
None,
|
||||||
|
CancellationToken::new(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import 'package:localsend_isolates/rust/frb_generated.dart';
|
|||||||
|
|
||||||
part 'http.freezed.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`
|
// 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.
|
/// Creates an HTTP client.
|
||||||
@@ -46,6 +46,7 @@ abstract class RsHttpClient implements RustOpaqueInterface {
|
|||||||
required PrepareUploadRequestDto payload,
|
required PrepareUploadRequestDto payload,
|
||||||
String? publicKey,
|
String? publicKey,
|
||||||
String? pin,
|
String? pin,
|
||||||
|
required RsCancellationToken cancelToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<ResultWithPublicKeyRegisterResponseDto> register({
|
Future<ResultWithPublicKeyRegisterResponseDto> register({
|
||||||
|
|||||||
@@ -53,11 +53,25 @@ abstract class RsHttpServer implements RustOpaqueInterface {
|
|||||||
/// transfer on the receiving side.
|
/// transfer on the receiving side.
|
||||||
///
|
///
|
||||||
/// Uploads that are already in progress still run to completion, but new
|
/// 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
|
/// No [RsServerEvent::SessionEnd] is emitted: the application initiated
|
||||||
/// the cancellation itself.
|
/// the cancellation itself.
|
||||||
Future<void> cancelSession({required String sessionId});
|
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.
|
/// Emits server events until the server is stopped.
|
||||||
/// Can only be listened to once.
|
/// Can only be listened to once.
|
||||||
///
|
///
|
||||||
@@ -65,20 +79,6 @@ abstract class RsHttpServer implements RustOpaqueInterface {
|
|||||||
/// events are all emitted on the same stream.
|
/// events are all emitted on the same stream.
|
||||||
Stream<RsServerEvent> listen();
|
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
|
/// Answers the pending [RsServerEvent::WebFileDownload] event with the source
|
||||||
/// the file content should be read from (either a path or a file descriptor).
|
/// 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';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => -2071906741;
|
int get rustContentHash => 1795427439;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig(
|
static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig(
|
||||||
stem: 'rust_lib_localsend_app',
|
stem: 'rust_lib_localsend_app',
|
||||||
@@ -128,6 +128,7 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
required PrepareUploadRequestDto payload,
|
required PrepareUploadRequestDto payload,
|
||||||
String? publicKey,
|
String? publicKey,
|
||||||
String? pin,
|
String? pin,
|
||||||
|
required RsCancellationToken cancelToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<ResultWithPublicKeyRegisterResponseDto> crateApiHttpRsHttpClientRegister({
|
Future<ResultWithPublicKeyRegisterResponseDto> crateApiHttpRsHttpClientRegister({
|
||||||
@@ -156,12 +157,12 @@ abstract class RustLibApi extends BaseApi {
|
|||||||
|
|
||||||
Future<void> crateApiServerRsHttpServerCancelSession({required RsHttpServer that, required String sessionId});
|
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});
|
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({
|
Future<void> crateApiServerRsHttpServerRespondFileDownload({
|
||||||
required RsHttpServer that,
|
required RsHttpServer that,
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@@ -574,6 +575,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
required PrepareUploadRequestDto payload,
|
required PrepareUploadRequestDto payload,
|
||||||
String? publicKey,
|
String? publicKey,
|
||||||
String? pin,
|
String? pin,
|
||||||
|
required RsCancellationToken cancelToken,
|
||||||
}) {
|
}) {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
NormalTask(
|
NormalTask(
|
||||||
@@ -586,6 +588,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_box_autoadd_prepare_upload_request_dto(payload, serializer);
|
sse_encode_box_autoadd_prepare_upload_request_dto(payload, serializer);
|
||||||
sse_encode_opt_String(publicKey, serializer);
|
sse_encode_opt_String(publicKey, serializer);
|
||||||
sse_encode_opt_String(pin, 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_);
|
pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8, port: port_);
|
||||||
},
|
},
|
||||||
codec: SseCodec(
|
codec: SseCodec(
|
||||||
@@ -593,7 +596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
decodeErrorData: sse_decode_rs_http_client_error,
|
decodeErrorData: sse_decode_rs_http_client_error,
|
||||||
),
|
),
|
||||||
constMeta: kCrateApiHttpRsHttpClientPrepareUploadConstMeta,
|
constMeta: kCrateApiHttpRsHttpClientPrepareUploadConstMeta,
|
||||||
argValues: [that, protocol, ip, port, payload, publicKey, pin],
|
argValues: [that, protocol, ip, port, payload, publicKey, pin, cancelToken],
|
||||||
apiImpl: this,
|
apiImpl: this,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -601,7 +604,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
|
|
||||||
TaskConstMeta get kCrateApiHttpRsHttpClientPrepareUploadConstMeta => const TaskConstMeta(
|
TaskConstMeta get kCrateApiHttpRsHttpClientPrepareUploadConstMeta => const TaskConstMeta(
|
||||||
debugName: 'RsHttpClient_prepare_upload',
|
debugName: 'RsHttpClient_prepare_upload',
|
||||||
argNames: ['that', 'protocol', 'ip', 'port', 'payload', 'publicKey', 'pin'],
|
argNames: ['that', 'protocol', 'ip', 'port', 'payload', 'publicKey', 'pin', 'cancelToken'],
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -739,6 +742,60 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
argNames: ['that', 'sessionId'],
|
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
|
@override
|
||||||
Stream<RsServerEvent> crateApiServerRsHttpServerListen({required RsHttpServer that}) {
|
Stream<RsServerEvent> crateApiServerRsHttpServerListen({required RsHttpServer that}) {
|
||||||
final sink = RustStreamSink<RsServerEvent>();
|
final sink = RustStreamSink<RsServerEvent>();
|
||||||
@@ -749,7 +806,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
|
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer);
|
||||||
sse_encode_StreamSink_rs_server_event_Sse(sink, 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(
|
codec: SseCodec(
|
||||||
decodeSuccessData: sse_decode_unit,
|
decodeSuccessData: sse_decode_unit,
|
||||||
@@ -769,60 +826,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
argNames: ['that', 'sink'],
|
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
|
@override
|
||||||
Future<void> crateApiServerRsHttpServerRespondFileDownload({
|
Future<void> crateApiServerRsHttpServerRespondFileDownload({
|
||||||
required RsHttpServer that,
|
required RsHttpServer that,
|
||||||
@@ -5573,6 +5576,7 @@ class RsHttpClientImpl extends RustOpaque implements RsHttpClient {
|
|||||||
required PrepareUploadRequestDto payload,
|
required PrepareUploadRequestDto payload,
|
||||||
String? publicKey,
|
String? publicKey,
|
||||||
String? pin,
|
String? pin,
|
||||||
|
required RsCancellationToken cancelToken,
|
||||||
}) => RustLib.instance.api.crateApiHttpRsHttpClientPrepareUpload(
|
}) => RustLib.instance.api.crateApiHttpRsHttpClientPrepareUpload(
|
||||||
that: this,
|
that: this,
|
||||||
protocol: protocol,
|
protocol: protocol,
|
||||||
@@ -5581,6 +5585,7 @@ class RsHttpClientImpl extends RustOpaque implements RsHttpClient {
|
|||||||
payload: payload,
|
payload: payload,
|
||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
pin: pin,
|
pin: pin,
|
||||||
|
cancelToken: cancelToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<ResultWithPublicKeyRegisterResponseDto> register({
|
Future<ResultWithPublicKeyRegisterResponseDto> register({
|
||||||
@@ -5638,12 +5643,28 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer {
|
|||||||
/// transfer on the receiving side.
|
/// transfer on the receiving side.
|
||||||
///
|
///
|
||||||
/// Uploads that are already in progress still run to completion, but new
|
/// 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
|
/// No [RsServerEvent::SessionEnd] is emitted: the application initiated
|
||||||
/// the cancellation itself.
|
/// the cancellation itself.
|
||||||
Future<void> cancelSession({required String sessionId}) =>
|
Future<void> cancelSession({required String sessionId}) =>
|
||||||
RustLib.instance.api.crateApiServerRsHttpServerCancelSession(that: this, sessionId: 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.
|
/// Emits server events until the server is stopped.
|
||||||
/// Can only be listened to once.
|
/// Can only be listened to once.
|
||||||
///
|
///
|
||||||
@@ -5653,22 +5674,6 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer {
|
|||||||
that: this,
|
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
|
/// Answers the pending [RsServerEvent::WebFileDownload] event with the source
|
||||||
/// the file content should be read from (either a path or a file descriptor).
|
/// 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
|
/// 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
|
/// error response. Does nothing if the download was already answered with a
|
||||||
/// [HttpServerFileDownloadTargetTask].
|
/// [HttpServerFileDownloadTargetTask].
|
||||||
class HttpServerRejectFileDownloadTask implements BaseHttpServerTask {
|
class HttpServerFailFileDownloadTask implements BaseHttpServerTask {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
|
|
||||||
HttpServerRejectFileDownloadTask({
|
HttpServerFailFileDownloadTask({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
required this.fileId,
|
required this.fileId,
|
||||||
});
|
});
|
||||||
@@ -575,12 +575,12 @@ Future<void> setupHttpServerIsolate(
|
|||||||
fileDescriptor: targetTask.fileDescriptor,
|
fileDescriptor: targetTask.fileDescriptor,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case HttpServerRejectFileDownloadTask rejectTask:
|
case HttpServerFailFileDownloadTask failTask:
|
||||||
await ref
|
await ref
|
||||||
.read(httpServerProvider)
|
.read(httpServerProvider)
|
||||||
.rejectFileDownload(
|
.failFileDownload(
|
||||||
sessionId: rejectTask.sessionId,
|
sessionId: failTask.sessionId,
|
||||||
fileId: rejectTask.fileId,
|
fileId: failTask.fileId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -642,12 +642,12 @@ Future<void> _handleFileUpload({
|
|||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
_logger.severe('Failed to prepare save target', 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.
|
// sender's request which would otherwise hang forever.
|
||||||
try {
|
try {
|
||||||
await ref.read(httpServerProvider).rejectFileUpload(sessionId: sessionId, fileId: fileId);
|
await ref.read(httpServerProvider).failFileUpload(sessionId: sessionId, fileId: fileId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.warning('Failed to reject file upload', e);
|
_logger.warning('Could not fail the pending file upload', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitFailed(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
|
/// for the file content could be resolved. The web client receives an error
|
||||||
/// response for this file.
|
/// response for this file.
|
||||||
/// Does nothing if the download was already answered with a
|
/// Does nothing if the download was already answered with a
|
||||||
/// [IsolateHttpServerFileDownloadTargetAction].
|
/// [IsolateHttpServerFileDownloadTargetAction].
|
||||||
class IsolateHttpServerRejectFileDownloadAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
class IsolateHttpServerFailFileDownloadAction extends ReduxAction<IsolateController, ParentIsolateState> {
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
|
|
||||||
IsolateHttpServerRejectFileDownloadAction({
|
IsolateHttpServerFailFileDownloadAction({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
required this.fileId,
|
required this.fileId,
|
||||||
});
|
});
|
||||||
@@ -416,7 +416,7 @@ class IsolateHttpServerRejectFileDownloadAction extends ReduxAction<IsolateContr
|
|||||||
SendToIsolateData(
|
SendToIsolateData(
|
||||||
syncState: null,
|
syncState: null,
|
||||||
data: IsolateTask(
|
data: IsolateTask(
|
||||||
data: HttpServerRejectFileDownloadTask(
|
data: HttpServerFailFileDownloadTask(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
fileId: fileId,
|
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
|
/// prepared. The upload request fails with an error response and the file is
|
||||||
/// marked as failed; the session itself continues.
|
/// marked as failed; the session itself continues.
|
||||||
/// Does nothing if the upload was already answered via [respondFileUpload].
|
/// Does nothing if the upload was already answered via [respondFileUpload].
|
||||||
Future<void> rejectFileUpload({required String sessionId, required String fileId}) async {
|
Future<void> failFileUpload({required String sessionId, required String fileId}) async {
|
||||||
await _requireServer().rejectFileUpload(sessionId: sessionId, fileId: fileId);
|
await _requireServer().failFileUpload(sessionId: sessionId, fileId: fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancels the active upload session. Uploads that are already in progress
|
/// 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.
|
/// session can be created. No session-end event is emitted.
|
||||||
Future<void> cancelSession({required String sessionId}) async {
|
Future<void> cancelSession({required String sessionId}) async {
|
||||||
await _requireServer().cancelSession(sessionId: sessionId);
|
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.
|
/// be resolved. The download request fails with an error response.
|
||||||
/// Does nothing if the download was already answered via [respondFileDownload].
|
/// Does nothing if the download was already answered via [respondFileDownload].
|
||||||
Future<void> rejectFileDownload({required String sessionId, required String fileId}) async {
|
Future<void> failFileDownload({required String sessionId, required String fileId}) async {
|
||||||
await _requireServer().rejectFileDownload(sessionId: sessionId, fileId: fileId);
|
await _requireServer().failFileDownload(sessionId: sessionId, fileId: fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops the server. The event stream returned by [start] will end.
|
/// Stops the server. The event stream returned by [start] will end.
|
||||||
|
|||||||
@@ -67,10 +67,19 @@ impl RsHttpClient {
|
|||||||
payload: PrepareUploadRequestDto,
|
payload: PrepareUploadRequestDto,
|
||||||
public_key: Option<String>,
|
public_key: Option<String>,
|
||||||
pin: Option<String>,
|
pin: Option<String>,
|
||||||
|
cancel_token: &RsCancellationToken,
|
||||||
) -> Result<PrepareUploadResult, RsHttpClientError> {
|
) -> Result<PrepareUploadResult, RsHttpClientError> {
|
||||||
let response = self
|
let response = self
|
||||||
.inner
|
.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
|
.await
|
||||||
.map_err(RsHttpClientError::from)?;
|
.map_err(RsHttpClientError::from)?;
|
||||||
|
|
||||||
@@ -208,7 +217,7 @@ impl From<ClientError> for RsHttpClientError {
|
|||||||
/// Renders an error together with everything that caused it.
|
/// Renders an error together with everything that caused it.
|
||||||
///
|
///
|
||||||
/// [`reqwest::Error`] alone only says "error sending request for url (...)".
|
/// [`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;
|
use std::fmt::Write;
|
||||||
|
|
||||||
let mut message = e.to_string();
|
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 application failed to prepare a save target for the file.
|
||||||
///
|
///
|
||||||
/// The upload request fails with an error response and the file is marked
|
/// The upload request fails with an error response and the file is marked
|
||||||
/// as failed. Does nothing if the upload was already answered.
|
/// 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.
|
// Dropping the responder fails the request waiting for the target.
|
||||||
self.pending_uploads
|
self.pending_uploads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -509,12 +509,12 @@ impl RsHttpServer {
|
|||||||
Ok(())
|
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 application failed to resolve a source for the file content.
|
||||||
///
|
///
|
||||||
/// The download request fails with an error response.
|
/// The download request fails with an error response.
|
||||||
/// Does nothing if the download was already answered.
|
/// 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.
|
// Dropping the responder fails the request waiting for the content.
|
||||||
self.pending_downloads
|
self.pending_downloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -526,7 +526,7 @@ impl RsHttpServer {
|
|||||||
/// transfer on the receiving side.
|
/// transfer on the receiving side.
|
||||||
///
|
///
|
||||||
/// Uploads that are already in progress still run to completion, but new
|
/// 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
|
/// No [RsServerEvent::SessionEnd] is emitted: the application initiated
|
||||||
/// the cancellation itself.
|
/// the cancellation itself.
|
||||||
pub async fn cancel_session(&self, session_id: String) {
|
pub async fn cancel_session(&self, session_id: String) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
|||||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||||
);
|
);
|
||||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
|
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
|
// Section: executor
|
||||||
|
|
||||||
@@ -506,16 +506,27 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl(
|
|||||||
<crate::api::model::PrepareUploadRequestDto>::sse_decode(&mut deserializer);
|
<crate::api::model::PrepareUploadRequestDto>::sse_decode(&mut deserializer);
|
||||||
let api_public_key = <Option<String>>::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_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();
|
deserializer.end();
|
||||||
move |context| async move {
|
move |context| async move {
|
||||||
transform_result_sse::<_, crate::api::http::RsHttpClientError>(
|
transform_result_sse::<_, crate::api::http::RsHttpClientError>(
|
||||||
(move || async move {
|
(move || async move {
|
||||||
let mut api_that_guard = None;
|
let mut api_that_guard = None;
|
||||||
|
let mut api_cancel_token_guard = None;
|
||||||
let decode_indices_ =
|
let decode_indices_ =
|
||||||
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
|
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
|
||||||
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
|
vec![
|
||||||
&api_that, 0, false,
|
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_ {
|
for i in decode_indices_ {
|
||||||
match i {
|
match i {
|
||||||
@@ -523,10 +534,15 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl(
|
|||||||
api_that_guard =
|
api_that_guard =
|
||||||
Some(api_that.lockable_decode_async_ref().await)
|
Some(api_that.lockable_decode_async_ref().await)
|
||||||
}
|
}
|
||||||
|
1 => {
|
||||||
|
api_cancel_token_guard =
|
||||||
|
Some(api_cancel_token.lockable_decode_async_ref().await)
|
||||||
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let api_that_guard = api_that_guard.unwrap();
|
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(
|
let output_ok = crate::api::http::RsHttpClient::prepare_upload(
|
||||||
&*api_that_guard,
|
&*api_that_guard,
|
||||||
api_protocol,
|
api_protocol,
|
||||||
@@ -535,6 +551,7 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl(
|
|||||||
api_payload,
|
api_payload,
|
||||||
api_public_key,
|
api_public_key,
|
||||||
api_pin,
|
api_pin,
|
||||||
|
&*api_cancel_token_guard,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(output_ok)
|
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(
|
fn wire__crate__api__server__RsHttpServer_listen_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
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(
|
fn wire__crate__api__server__RsHttpServer_respond_file_download_impl(
|
||||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||||
@@ -4460,19 +4477,19 @@ fn pde_ffi_dispatcher_primary_impl(
|
|||||||
rust_vec_len,
|
rust_vec_len,
|
||||||
data_len,
|
data_len,
|
||||||
),
|
),
|
||||||
12 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len),
|
12 => wire__crate__api__server__RsHttpServer_fail_file_download_impl(
|
||||||
13 => wire__crate__api__server__RsHttpServer_reject_file_download_impl(
|
|
||||||
port,
|
port,
|
||||||
ptr,
|
ptr,
|
||||||
rust_vec_len,
|
rust_vec_len,
|
||||||
data_len,
|
data_len,
|
||||||
),
|
),
|
||||||
14 => wire__crate__api__server__RsHttpServer_reject_file_upload_impl(
|
13 => wire__crate__api__server__RsHttpServer_fail_file_upload_impl(
|
||||||
port,
|
port,
|
||||||
ptr,
|
ptr,
|
||||||
rust_vec_len,
|
rust_vec_len,
|
||||||
data_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(
|
15 => wire__crate__api__server__RsHttpServer_respond_file_download_impl(
|
||||||
port,
|
port,
|
||||||
ptr,
|
ptr,
|
||||||
|
|||||||
Reference in New Issue
Block a user