diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05514eea..6b7ebccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,9 +77,16 @@ jobs: echo "Inno Setup version is $VERSION" echo "inno_version=$VERSION" >> $GITHUB_ENV - - name: Compare pubspec and Inno Setup versions + - name: Extract version from CLI Cargo.toml + id: cli_version run: | - if [ "$pubspec_version" != "$inno_version" ]; then + VERSION=$(grep '^version = ' cli/Cargo.toml | sed 's/version = "//' | sed 's/"//') + echo "CLI version is $VERSION" + echo "cli_version=$VERSION" >> $GITHUB_ENV + + - name: Compare pubspec, Inno Setup and CLI versions + run: | + if [ "$pubspec_version" != "$inno_version" ] || [ "$pubspec_version" != "$cli_version" ]; then echo "Version mismatch detected!" exit 1 else diff --git a/AGENTS.md b/AGENTS.md index c6ead844..866f6957 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ This is a multi-language monorepo: a Flutter app on top of a Rust protocol imple | `packages/core/` | Rust crate `localsend`: protocol, HTTP server/client, crypto, WebRTC. No Flutter dependency. | | `packages/typed_isolates/` | Small standalone package wrapping Dart `Isolate` with typed send/receive channels. | | `server/` | Axum WebSocket signaling server for WebRTC (`/v1/ws`). Deployed separately, see `server/Dockerfile`. | -| `cli/` | Rust CLI crate — currently a stub. | +| `cli/` | Rust CLI crate (`localsend-cli`): interactive terminal client on top of `packages/core` (v2 HTTP + multicast). | | `support/scripts/` | Release/packaging scripts (per-platform builds, MSIX, Inno Setup, FOSS stripping). | There is no Cargo workspace; `packages/core`, `packages/localsend_isolates/rust`, `server`, and `cli` are independent crates. @@ -133,4 +133,4 @@ Slang, source files in `app/assets/i18n/` (`.json` plus `_missing_transl ## Release notes -`app/pubspec.yaml`'s version must match `#define MyAppVersion` in `support/scripts/compile_windows_exe-inno.iss` — CI fails on a mismatch. Platform build commands and release steps are documented in `README.md` ("Building") and `CONTRIBUTING.md` ("Release"). \ No newline at end of file +`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"). \ No newline at end of file diff --git a/app/assets/web/error-403.html b/app/assets/web/error-403.html deleted file mode 100644 index 69922bff..00000000 --- a/app/assets/web/error-403.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - LocalSend - - -

403 Forbidden

-

You don't have permission to access this resource.

- - diff --git a/app/assets/web/index.html b/app/assets/web/index.html deleted file mode 100644 index 974ea94c..00000000 --- a/app/assets/web/index.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - LocalSend - - - - - - -

LocalSend

-

-
-
- - diff --git a/app/assets/web/main.js b/app/assets/web/main.js deleted file mode 100644 index db5481dc..00000000 --- a/app/assets/web/main.js +++ /dev/null @@ -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 += '' + - '
' + (i + 1) + '
' + - '
' + escapeHtml(file.fileName) + '
' + - '
' + formatBytes(file.size) + '
' + - '
'; - } - - 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(); diff --git a/app/lib/gen/assets.gen.dart b/app/lib/gen/assets.gen.dart index 82e1f8c3..6450a70d 100644 --- a/app/lib/gen/assets.gen.dart +++ b/app/lib/gen/assets.gen.dart @@ -42,28 +42,11 @@ class $AssetsImgGen { List 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 get values => [error403, index, main]; -} - class Assets { const Assets._(); static const String changelog = 'assets/CHANGELOG.md'; static const $AssetsImgGen img = $AssetsImgGen(); - static const $AssetsWebGen web = $AssetsWebGen(); /// List of all assets static List get values => [changelog]; diff --git a/app/lib/provider/network/send_provider.dart b/app/lib/provider/network/send_provider.dart index 095714d1..ab80cc5f 100644 --- a/app/lib/provider/network/send_provider.dart +++ b/app/lib/provider/network/send_provider.dart @@ -51,6 +51,12 @@ class SendNotifier extends Notifier> { /// Session ID -> Cancel token final _hashCancelTokens = {}; + /// 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 = {}; + @override Map init() { return {}; @@ -195,92 +201,99 @@ class SendNotifier extends Notifier> { bool invalidPin; bool pinFirstAttempt = true; String? pin; - do { - invalidPin = false; - try { - response = await client.prepareUpload( - protocol: target.getProtocolType(), - ip: target.ip!, - port: target.port, - payload: requestDto, - // The peer is already verified during the TLS handshake by the - // fingerprint the client is pinned to. - publicKey: null, - pin: pin, - ); - } on rust_http.RsHttpClientError_StatusCode catch (e) { - switch (e.status) { - case 401: - invalidPin = true; + final prepareUploadCancelToken = rust_cancel.createCancellationToken(); + _prepareUploadCancelTokens[sessionId] = prepareUploadCancelToken; + try { + do { + invalidPin = false; + try { + response = await client.prepareUpload( + protocol: target.getProtocolType(), + ip: target.ip!, + port: target.port, + payload: requestDto, + // The peer is already verified during the TLS handshake by the + // fingerprint the client is pinned to. + publicKey: null, + pin: pin, + cancelToken: prepareUploadCancelToken, + ); + } on rust_http.RsHttpClientError_StatusCode catch (e) { + switch (e.status) { + case 401: + invalidPin = true; - // wait until animation is finished - await sleepAsync(500); + // wait until animation is finished + await sleepAsync(500); - pin = await showDialog( - context: Routerino.context, // ignore: use_build_context_synchronously - builder: (_) => PinDialog( - obscureText: true, - showInvalidPin: !pinFirstAttempt, - ), - ); + pin = await showDialog( + context: Routerino.context, // ignore: use_build_context_synchronously + builder: (_) => PinDialog( + obscureText: true, + showInvalidPin: !pinFirstAttempt, + ), + ); - pinFirstAttempt = false; + pinFirstAttempt = false; - if (pin == null) { + if (pin == null) { + state = state.updateSession( + sessionId: sessionId, + state: (s) => s?.copyWith( + status: SessionStatus.canceledBySender, + ), + ); + return; + } + break; + case 403: state = state.updateSession( sessionId: sessionId, state: (s) => s?.copyWith( - status: SessionStatus.canceledBySender, + status: SessionStatus.declined, ), ); return; - } - break; - case 403: - state = state.updateSession( - sessionId: sessionId, - state: (s) => s?.copyWith( - status: SessionStatus.declined, - ), - ); - return; - case 409: - state = state.updateSession( - sessionId: sessionId, - state: (s) => s?.copyWith( - status: SessionStatus.recipientBusy, - ), - ); - return; - case 429: - state = state.updateSession( - sessionId: sessionId, - state: (s) => s?.copyWith( - status: SessionStatus.tooManyAttempts, - ), - ); - return; - default: - state = state.updateSession( - sessionId: sessionId, - state: (s) => s?.copyWith( - status: SessionStatus.finishedWithErrors, - errorMessage: e.humanErrorMessage, - ), - ); - return; + case 409: + state = state.updateSession( + sessionId: sessionId, + state: (s) => s?.copyWith( + status: SessionStatus.recipientBusy, + ), + ); + return; + case 429: + state = state.updateSession( + sessionId: sessionId, + state: (s) => s?.copyWith( + status: SessionStatus.tooManyAttempts, + ), + ); + return; + default: + state = state.updateSession( + sessionId: sessionId, + state: (s) => s?.copyWith( + status: SessionStatus.finishedWithErrors, + errorMessage: e.humanErrorMessage, + ), + ); + return; + } + } catch (e) { + state = state.updateSession( + sessionId: sessionId, + state: (s) => s?.copyWith( + status: SessionStatus.finishedWithErrors, + errorMessage: e.humanErrorMessage, + ), + ); + return; } - } catch (e) { - state = state.updateSession( - sessionId: sessionId, - state: (s) => s?.copyWith( - status: SessionStatus.finishedWithErrors, - errorMessage: e.humanErrorMessage, - ), - ); - return; - } - } while (invalidPin); + } while (invalidPin); + } finally { + _prepareUploadCancelTokens.remove(sessionId); + } if (response == null) { return; @@ -672,6 +685,7 @@ class SendNotifier extends Notifier> { void _cancelRunningRequests(SendSessionState state) { _hashCancelTokens.remove(state.sessionId)?.cancel(); + _prepareUploadCancelTokens.remove(state.sessionId)?.cancel(); for (final task in state.sendingTasks ?? []) { ref @@ -692,6 +706,7 @@ class SendNotifier extends Notifier> { } TransferNotification.stop(sessionId); _hashCancelTokens.remove(sessionId)?.cancel(); + _prepareUploadCancelTokens.remove(sessionId)?.cancel(); state = state.removeSession(ref, sessionId); if (sessionState.status == SessionStatus.finished && ref.read(settingsProvider).sendMode == SendMode.single) { // clear selected files @@ -707,6 +722,10 @@ class SendNotifier extends Notifier> { cancelToken.cancel(); } _hashCancelTokens.clear(); + for (final cancelToken in _prepareUploadCancelTokens.values) { + cancelToken.cancel(); + } + _prepareUploadCancelTokens.clear(); state = {}; ref.notifier(progressProvider).removeAllSessions(); } diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index ab881ab7..219dd561 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -223,8 +223,8 @@ class ReceiveController { final receiveState = server.getStateOrNull()?.session; const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors}; if (receiveState == null || receiveState.sessionId != event.sessionId || !allowedStates.contains(receiveState.status)) { - _logger.warning('Rejecting upload of file ${event.fileId}: no matching active session'); - // Reject the upload (and any further ones) by cancelling the session on the Rust side. + _logger.warning('Failing upload of file ${event.fileId}: no matching active session'); + // Fail the upload (and any further ones) by cancelling the session on the Rust side. server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: event.sessionId)); return; } @@ -628,7 +628,7 @@ class ReceiveController { } /// In addition to [closeSession], this method also - /// - cancels the session on the Rust server so that further uploads are rejected + /// - cancels the session on the Rust server so that further uploads fail /// - notifies the sender that the session has been canceled void cancelSession() async { final session = server.getStateOrNull()?.session; @@ -637,7 +637,7 @@ class ReceiveController { return; } - // reject further uploads + // fail further uploads server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerCancelSessionAction(sessionId: session.sessionId)); // notify sender diff --git a/app/lib/provider/network/server/controller/send_controller.dart b/app/lib/provider/network/server/controller/send_controller.dart index f60df1ab..d3e2645e 100644 --- a/app/lib/provider/network/server/controller/send_controller.dart +++ b/app/lib/provider/network/server/controller/send_controller.dart @@ -138,7 +138,7 @@ class SendController { } catch (e, st) { _logger.severe('Failed to resolve source for web send file ${event.fileId}', e, st); // Unblock the web client's request waiting for the content source. - server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerRejectFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId)); + server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerFailFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId)); return; } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 36a2b098..c6e4b26c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -110,7 +110,6 @@ flutter: assets: - assets/img/ - - assets/web/ - assets/CHANGELOG.md flutter_gen: diff --git a/app/test/mocks.mocks.dart b/app/test/mocks.mocks.dart index 07278432..a257ab67 100644 --- a/app/test/mocks.mocks.dart +++ b/app/test/mocks.mocks.dart @@ -7,13 +7,13 @@ import 'dart:async' as _i4; import 'package:flutter/material.dart' as _i8; import 'package:localsend_app/gen/strings.g.dart' as _i10; -import 'package:localsend_isolates/model/device.dart' as _i12; -import 'package:localsend_isolates/model/stored_security_context.dart' as _i2; import 'package:localsend_app/model/persistence/color_mode.dart' as _i9; import 'package:localsend_app/model/persistence/favorite_device.dart' as _i6; import 'package:localsend_app/model/persistence/receive_history_entry.dart' as _i5; import 'package:localsend_app/model/send_mode.dart' as _i11; import 'package:localsend_app/provider/persistence_provider.dart' as _i3; +import 'package:localsend_isolates/model/device.dart' as _i12; +import 'package:localsend_isolates/model/stored_security_context.dart' as _i2; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i7; import 'package:shared_preferences/shared_preferences.dart' as _i13; diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 00000000..2f7896d1 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/cli/Cargo.lock b/cli/Cargo.lock new file mode 100644 index 00000000..2c96b201 --- /dev/null +++ b/cli/Cargo.lock @@ -0,0 +1,4768 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive 0.6.0", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead", + "cipher", + "ctr", + "subtle", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs 0.7.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dtls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f531dd7c181beaf3cebab3716afa4d0d41ab888be85232583f56bbaf07ca208a" +dependencies = [ + "aes", + "aes-gcm", + "async-trait", + "bincode", + "byteorder", + "cbc", + "ccm", + "chacha20poly1305", + "der-parser 9.0.0", + "hmac", + "log", + "p256", + "p384", + "portable-atomic", + "rand 0.9.5", + "rand_core 0.6.4", + "rcgen 0.13.2", + "ring", + "rustls", + "sec1", + "serde", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "webrtc-util", + "x25519-dalek", + "x509-parser 0.16.0", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "interceptor" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea51375727680dc15f06e8ad90fa31df75d79dd030100e8ad60eef1c27fe2c98" +dependencies = [ + "async-trait", + "bytes", + "futures", + "log", + "portable-atomic", + "rand 0.9.5", + "rtcp", + "rtp", + "thiserror 1.0.69", + "tokio", + "waitgroup", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "localsend" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "ed25519-dalek", + "flate2", + "form_urlencoded", + "futures-util", + "http-body-util", + "hyper", + "hyper-util", + "if-addrs", + "lru 0.16.4", + "pem 3.0.6", + "percent-encoding", + "rand 0.9.5", + "reqwest", + "rsa", + "rustls", + "serde", + "serde_json", + "sha2", + "socket2 0.6.5", + "thiserror 2.0.19", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-subscriber", + "tungstenite", + "uuid", + "webrtc", + "x509-parser 0.18.1", +] + +[[package]] +name = "localsend-cli" +version = "1.17.0" +dependencies = [ + "anyhow", + "bytes", + "clap", + "crossterm", + "dirs", + "futures-util", + "gethostname", + "if-addrs", + "localsend", + "mime_guess", + "pem 4.0.0", + "ratatui", + "ratatui-explorer", + "rcgen 0.14.8", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "toml", + "uuid", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs 0.7.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.0", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru 0.18.1", + "palette", + "serde", + "strum", + "thiserror 2.0.19", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-explorer" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1034d08c806b090cf2eb450f60717e38d28b3a0a9fae01eee282db488d2e513b" +dependencies = [ + "educe", + "ratatui", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem 3.0.6", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.16.0", + "yasna 0.5.2", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem 3.0.6", + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna 0.6.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rtcp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d30d1c4091644431c22acf9f8be6191b56805e0e977f15ca7104b4a6d6eaec" +dependencies = [ + "bytes", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rtp" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f126f38ea84c02480e32e547c1459a939052f74fb92117ac3eef23fdac6b023" +dependencies = [ + "bytes", + "memchr", + "portable-atomic", + "rand 0.9.5", + "serde", + "thiserror 1.0.69", + "webrtc-util", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c374dceda16965d541c8800ce9cc4e1c14acfd661ddf7952feeedc3411e5c6" +dependencies = [ + "rand 0.9.5", + "substring", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "stun" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a512c5d501e3e3b5a4bb3e8e31462d56d54a66b95a28b8596e14422bf21c32b" +dependencies = [ + "base64 0.22.1", + "crc", + "lazy_static", + "md-5", + "rand 0.9.5", + "ring", + "subtle", + "thiserror 1.0.69", + "tokio", + "url", + "webrtc-util", +] + +[[package]] +name = "substring" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ee6433ecef213b2e72f587ef64a2f5943e7cd16fbd82dbe8bc07486c534c86" +dependencies = [ + "autocfg", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + +[[package]] +name = "turn" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed995882f66ab94238de77c62e5e778389698ab700afa4696f4754da8f457cb" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "log", + "md-5", + "portable-atomic", + "rand 0.9.5", + "ring", + "stun", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "webrtc-util", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "waitgroup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1f50000a783467e6c0200f9d10642f4bc424e39efc1b770203e88b488f79292" +dependencies = [ + "atomic-waker", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webrtc" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08fd686c0920ac08f3a57eacc48e31f0e4ca1ffefba4478784606f78c14e83ad" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dtls", + "hex", + "interceptor", + "lazy_static", + "log", + "portable-atomic", + "rand 0.9.5", + "rcgen 0.13.2", + "regex", + "ring", + "rtcp", + "rtp", + "sdp", + "serde", + "serde_json", + "sha2", + "smol_str", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "unicase", + "url", + "waitgroup", + "webrtc-data", + "webrtc-ice", + "webrtc-mdns", + "webrtc-media", + "webrtc-sctp", + "webrtc-srtp", + "webrtc-util", +] + +[[package]] +name = "webrtc-data" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062a5438d63bb0756a221693d76cc0dd6119affee1dfdfe57abe3a2a8c8b3eea" +dependencies = [ + "bytes", + "log", + "portable-atomic", + "thiserror 1.0.69", + "tokio", + "webrtc-sctp", + "webrtc-util", +] + +[[package]] +name = "webrtc-ice" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cb13fd1a373e68addc4bba0c8ca058627518e54342583d024bdcbb8ae5d97d" +dependencies = [ + "arc-swap", + "async-trait", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "serde", + "serde_json", + "stun", + "thiserror 1.0.69", + "tokio", + "turn", + "url", + "uuid", + "waitgroup", + "webrtc-mdns", + "webrtc-util", +] + +[[package]] +name = "webrtc-mdns" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a17279a067e75df72ce923fdeb7f04cd808f6f5aa4910dc6bcb4fbe66b396ace" +dependencies = [ + "log", + "socket2 0.5.10", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-media" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a84c910fec0848fd5a0d8a5651e0ddbdedaf25a7d3ae3f0b15f71ac73a1773" +dependencies = [ + "byteorder", + "bytes", + "rand 0.9.5", + "rtp", + "thiserror 1.0.69", +] + +[[package]] +name = "webrtc-sctp" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f985465467d8910c1f8ac4382cd64f83b1f6a1a75021a82b221546f6fb3b856f" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "crc", + "log", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-srtp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d8cdc33413f1d0192670a80ce93d17cb78d57fe3a2414be30d6f6dff121123" +dependencies = [ + "aead", + "aes", + "aes-gcm", + "byteorder", + "bytes", + "ctr", + "hmac", + "log", + "rtcp", + "rtp", + "sha1", + "subtle", + "thiserror 1.0.69", + "tokio", + "webrtc-util", +] + +[[package]] +name = "webrtc-util" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c0c7e0c8f280f2bbfae442701465777ac07adaf46ce0c5863cd58e13fe472a" +dependencies = [ + "async-trait", + "bitflags 1.3.2", + "bytes", + "ipnet", + "lazy_static", + "log", + "nix 0.26.4", + "portable-atomic", + "rand 0.9.5", + "thiserror 1.0.69", + "tokio", + "winapi", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "ring", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs 0.7.2", + "data-encoding", + "der-parser 10.0.0", + "lazy_static", + "nom", + "oid-registry 0.8.1", + "ring", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 16d2db2f..cf9dbec6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,31 @@ [package] -name = "cli" -version = "0.1.0" +name = "localsend-cli" +version = "1.17.0" edition = "2024" +[[bin]] +name = "localsend-cli" +path = "src/main.rs" + [dependencies] +localsend = { path = "../packages/core", features = ["full"] } +anyhow = "1.0" +bytes = "1.11" +clap = { version = "4.6", features = ["derive", "env"] } +crossterm = "0.29" +dirs = "6.0" +futures-util = "0.3" +gethostname = "1.0" +if-addrs = "0.15" +mime_guess = "2.0" +pem = "4.0" +ratatui = "0.30" +ratatui-explorer = "0.3" +rcgen = "0.14" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" +tokio-util = { version = "0.7", features = ["rt"] } +toml = "1.1" +uuid = { version = "1", features = ["v4"] } diff --git a/cli/src/app/discovery.rs b/cli/src/app/discovery.rs new file mode 100644 index 00000000..4abbffb2 --- /dev/null +++ b/cli/src/app/discovery.rs @@ -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 + ), + ); + } + } +} diff --git a/cli/src/app/mod.rs b/cli/src/app/mod.rs new file mode 100644 index 00000000..4f0b71c5 --- /dev/null +++ b/cli/src/app/mod.rs @@ -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, + registry: DeviceRegistry, + + /// Config, identity and paired devices, see [`storage::Repository`]. + storage: storage::Repository, + + pending: Option, + receive: Option, + send: Option, + picker: Option, + + events_tx: mpsc::Sender, +} + +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::(64); + + // HTTP server (always TLS, like the app). + let (server_tx, mut server_rx) = mpsc::channel::(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::(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(rx: &mut Option>) -> Option { + 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 + } +} diff --git a/cli/src/app/receive.rs b/cli/src/app/receive.rs new file mode 100644 index 00000000..630dec63 --- /dev/null +++ b/cli/src/app/receive.rs @@ -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, + pub(super) decision_tx: oneshot::Sender, +} + +/// 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, + 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>, + 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, +} + +impl ReceiveSession { + fn new( + session_id: String, + alias: String, + sender: SenderTarget, + files: HashMap, + ) -> 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::() + } +} + +/// 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 = 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, + ) { + 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::(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::>(); + 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 = 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, + )); + } + } + } +} diff --git a/cli/src/app/sending.rs b/cli/src/app/sending.rs new file mode 100644 index 00000000..ef69fd0e --- /dev/null +++ b/cli/src/app/sending.rs @@ -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, + pub(super) alias: String, + pub(super) host: String, + pub(super) total_bytes: u64, + pub(super) sent: Arc, + 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) { + 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(), + )); + } +} diff --git a/cli/src/app/status.rs b/cli/src/app/status.rs new file mode 100644 index 00000000..05d75cd4 --- /dev/null +++ b/cli/src/app/status.rs @@ -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 = 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), + ) +} diff --git a/cli/src/banner.rs b/cli/src/banner.rs new file mode 100644 index 00000000..f8e2f921 --- /dev/null +++ b/cli/src/banner.rs @@ -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::>() + .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::>() + .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(), + ) +} diff --git a/cli/src/devices.rs b/cli/src/devices.rs new file mode 100644 index 00000000..c219340f --- /dev/null +++ b/cli/src/devices.rs @@ -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, + 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, +} + +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 { + 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)) + } +} diff --git a/cli/src/main.rs b/cli/src/main.rs index e7a11a96..fbff4cfb 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,44 @@ -fn main() { - println!("Hello, world!"); +mod app; +mod banner; +mod devices; +mod picker; +mod send_task; +mod storage; +mod ui; +mod util; + +use clap::Parser; +use std::path::PathBuf; + +/// LocalSend CLI +#[derive(Parser)] +#[command(name = "localsend-cli", version, about, after_help = HELP_SECTIONS)] +pub struct Args { + /// Device name shown to other devices [default: config.toml, else the hostname] + #[arg(long, env = "LOCALSEND_ALIAS")] + pub alias: Option, + + /// Port of the HTTP server [default: config.toml, else 53317] + #[arg(long, env = "LOCALSEND_PORT")] + pub port: Option, + + /// Directory where received files are saved [default: config.toml, else the Downloads folder] + #[arg(long, env = "LOCALSEND_DESTINATION")] + pub destination: Option, +} + +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)) } diff --git a/cli/src/picker.rs b/cli/src/picker.rs new file mode 100644 index 00000000..e800bb3f --- /dev/null +++ b/cli/src/picker.rs @@ -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>, + selected: Vec, + + /// 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, + + 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), + + /// 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 { + 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 { + 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::>() + .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)) +} diff --git a/cli/src/send_task.rs b/cli/src/send_task.rs new file mode 100644 index 00000000..5be08cab --- /dev/null +++ b/cli/src/send_task.rs @@ -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, +} + +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, + device: Device, + files: HashMap, + paths: HashMap, + progress: Arc, + cancel: SendCancel, + events: mpsc::Sender, +) { + send_inner(identity, device, files, paths, progress, cancel, &events).await; + let _ = events.send(AppEvent::SendEnded).await; +} + +async fn send_inner( + identity: Arc, + device: Device, + files: HashMap, + paths: HashMap, + progress: Arc, + cancel: SendCancel, + events: &mpsc::Sender, +) { + 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::(chunk) + }); + reqwest::Body::wrap_stream(stream) +} diff --git a/cli/src/storage/config.rs b/cli/src/storage/config.rs new file mode 100644 index 00000000..d85c2f8a --- /dev/null +++ b/cli/src/storage/config.rs @@ -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, + pub port: Option, + pub destination: Option, +} + +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 { + 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 { + 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, + } +} diff --git a/cli/src/storage/identity.rs b/cli/src/storage/identity.rs new file mode 100644 index 00000000..744706f7 --- /dev/null +++ b/cli/src/storage/identity.rs @@ -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 { + 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 { + 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 { + 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, + } + } +} diff --git a/cli/src/storage/mod.rs b/cli/src/storage/mod.rs new file mode 100644 index 00000000..28704614 --- /dev/null +++ b/cli/src/storage/mod.rs @@ -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, + + /// 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 { + 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") +} diff --git a/cli/src/storage/paired.rs b/cli/src/storage/paired.rs new file mode 100644 index 00000000..05636316 --- /dev/null +++ b/cli/src/storage/paired.rs @@ -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, +} + +/// 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 { + 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::(&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())) + } +} diff --git a/cli/src/ui.rs b/cli/src/ui.rs new file mode 100644 index 00000000..7a8b2b0d --- /dev/null +++ b/cli/src/ui.rs @@ -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, + + /// While suspended (the file picker owns the alternate screen), log lines + /// are buffered and flushed on [Ui::resume]. + suspended: bool, + buffer: Vec, +} + +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, 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) { + 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> { + 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 +} diff --git a/cli/src/util.rs b/cli/src/util.rs new file mode 100644 index 00000000..be0a8b5e --- /dev/null +++ b/cli/src/util.rs @@ -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 { + let Ok(interfaces) = if_addrs::get_if_addrs() else { + return Vec::new(); + }; + let mut addresses: Vec = 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 + } +} diff --git a/packages/core/src/http/client/mod.rs b/packages/core/src/http/client/mod.rs index c2b01a30..407b27b4 100644 --- a/packages/core/src/http/client/mod.rs +++ b/packages/core/src/http/client/mod.rs @@ -109,17 +109,18 @@ impl LsHttpClient { public_key: Option, payload: http::dto::PrepareUploadRequestDto, pin: Option<&str>, + cancel: tokio_util::sync::CancellationToken, ) -> Result { match self { LsHttpClient::V2(client) => { let result = client - .prepare_upload(protocol, ip, port, public_key, payload.into(), pin) + .prepare_upload(protocol, ip, port, public_key, payload.into(), pin, cancel) .await?; Ok(result.into()) } LsHttpClient::V3(client) => { client - .prepare_upload(protocol, ip, port, public_key, payload) + .prepare_upload(protocol, ip, port, public_key, payload, cancel) .await } } diff --git a/packages/core/src/http/client/v2.rs b/packages/core/src/http/client/v2.rs index d268e515..a885708e 100644 --- a/packages/core/src/http/client/v2.rs +++ b/packages/core/src/http/client/v2.rs @@ -119,6 +119,10 @@ impl LsHttpClientV2 { /// * `public_key` - Expected public key for verification (HTTPS only) /// * `payload` - Upload request with device info and file metadata /// * `pin` - Optional PIN if required by receiver + /// * `cancel` - Cancellation token; cancelling it aborts the request with + /// [`ClientError::Cancelled`]. Aborting closes the connection, which + /// tells the receiver that the sender is no longer waiting for a + /// decision. /// /// # Returns /// Session ID and accepted file tokens, or an error. @@ -139,6 +143,7 @@ impl LsHttpClientV2 { public_key: Option, payload: PrepareUploadRequestDtoV2, pin: Option<&str>, + cancel: CancellationToken, ) -> Result { let pin_params: &[(&'static str, &str)] = match &pin { Some(pin) => &[("pin", pin)], @@ -154,13 +159,17 @@ impl LsHttpClientV2 { } .to_string(); - let res = self + let send = self .client .post(&url) .header("Content-Type", "application/json") .body(serde_json::to_string(&payload)?) - .send() - .await?; + .send(); + + let res = tokio::select! { + res = send => res?, + _ = cancel.cancelled() => return Err(ClientError::Cancelled), + }; if protocol == ProtocolType::Https { super::verify_cert_from_res(&res, public_key)?; diff --git a/packages/core/src/http/client/v3.rs b/packages/core/src/http/client/v3.rs index 7ac3db9e..86c59939 100644 --- a/packages/core/src/http/client/v3.rs +++ b/packages/core/src/http/client/v3.rs @@ -133,6 +133,9 @@ impl LsHttpClientV3 { Ok(ResultWithPublicKey { public_key, body }) } + /// `cancel` is a cancellation token; cancelling it aborts the request with + /// [`ClientError::Cancelled`]. Aborting closes the connection, which tells + /// the receiver that the sender is no longer waiting for a decision. pub async fn prepare_upload( &self, protocol: ProtocolType, @@ -140,8 +143,9 @@ impl LsHttpClientV3 { port: u16, public_key: Option, payload: http::dto::PrepareUploadRequestDto, + cancel: CancellationToken, ) -> Result { - let res = self + let send = self .client .post( TargetUrl { @@ -155,8 +159,12 @@ impl LsHttpClientV3 { .to_string(), ) .body(serde_json::to_string(&payload)?) - .send() - .await?; + .send(); + + let res = tokio::select! { + res = send => res?, + _ = cancel.cancelled() => return Err(ClientError::Cancelled), + }; if protocol == ProtocolType::Https { super::verify_cert_from_res(&res, public_key)?; diff --git a/packages/core/src/http/server/common/session.rs b/packages/core/src/http/server/common/session.rs index 9f7246ec..9c7efb47 100644 --- a/packages/core/src/http/server/common/session.rs +++ b/packages/core/src/http/server/common/session.rs @@ -1,16 +1,32 @@ use crate::http::server::PeerIp; use crate::model::transfer::FileDto; use std::collections::HashMap; +use tokio_util::sync::CancellationToken; /// State of the single v2 upload session slot. pub(crate) enum SessionStateV2 { /// A prepare-upload request is waiting for the application's decision. - Pending, + Pending(PendingSessionV2), /// An accepted upload session. Active(UploadSessionV2), } +/// A prepare-upload request that is waiting for the application's decision. +/// +/// Senders on protocol 2.0/2.1 do not know the session ID before the +/// prepare-upload response, so they cancel the pending request with a +/// session-less `POST /cancel` from the same address; `cancel` interrupts +/// the waiting request handler. +pub(crate) struct PendingSessionV2 { + pub(crate) session_id: String, + + /// The IP address of the sender. Only this address may cancel the request. + pub(crate) sender_ip: PeerIp, + + pub(crate) cancel: CancellationToken, +} + pub(crate) struct UploadSessionV2 { pub(crate) session_id: String, diff --git a/packages/core/src/http/server/mod.rs b/packages/core/src/http/server/mod.rs index d338da48..65c63a3d 100644 --- a/packages/core/src/http/server/mod.rs +++ b/packages/core/src/http/server/mod.rs @@ -138,7 +138,7 @@ impl ServerHandle { /// e.g. because the user aborted the transfer on the receiving side. /// /// Uploads that are already in progress still run to completion, but new - /// upload requests are rejected and a new session can be created. + /// upload requests fail and a new session can be created. /// No [ServerEventV2::SessionEnd] is emitted: the application initiated /// the cancellation itself. /// diff --git a/packages/core/src/http/server/v2.rs b/packages/core/src/http/server/v2.rs index 60e9ed83..ad6298d3 100644 --- a/packages/core/src/http/server/v2.rs +++ b/packages/core/src/http/server/v2.rs @@ -9,7 +9,7 @@ use crate::http::server::common::query::parse_query; use crate::http::server::common::response::{empty_body, BoxedBody, JsonResponse}; use crate::http::server::common::save::{FileUploadTarget, SaveResult}; use crate::http::server::common::session::{ - FileStatusV2, SessionFileV2, SessionStateV2, UploadSessionV2, + FileStatusV2, PendingSessionV2, SessionFileV2, SessionStateV2, UploadSessionV2, }; use crate::http::server::PeerIp; use crate::http::server::{common, AppState, RequestClientInfo, V2State}; @@ -19,6 +19,7 @@ use hyper::{Request, Response, StatusCode}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; use uuid::Uuid; /// Events emitted by the v2 HTTP server that must be handled by the application. @@ -229,6 +230,9 @@ pub(crate) async fn prepare_upload( return Err(AppError::BadRequest("No files provided".to_string())); } + let session_id = Uuid::new_v4().to_string(); + let cancelled = CancellationToken::new(); + // Claim the single session slot. { let mut slot = v2.session.lock().await; @@ -238,11 +242,13 @@ pub(crate) async fn prepare_upload( "Blocked by another session".to_string(), )); } - *slot = Some(SessionStateV2::Pending); + *slot = Some(SessionStateV2::Pending(PendingSessionV2 { + session_id: session_id.clone(), + sender_ip: client_info.ip, + cancel: cancelled.clone(), + })); } - let session_id = Uuid::new_v4().to_string(); - // Frees the slot again if this request is aborted before a session is created. let mut pending_guard = PendingSessionGuard::new(v2.clone(), session_id.clone()); @@ -259,9 +265,20 @@ pub(crate) async fn prepare_upload( return Err(AppError::Status(StatusCode::INTERNAL_SERVER_ERROR)); } - let decision = decision_rx - .await - .map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?; + // The sender may cancel the request while the application is deciding. + // Returning with the guard still armed frees the slot and emits + // [ServerEventV2::PrepareUploadAborted], like a dropped connection. + let decision = tokio::select! { + decision = decision_rx => { + decision.map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))? + } + _ = cancelled.cancelled() => { + return Err(AppError::Message( + StatusCode::FORBIDDEN, + "Cancelled by sender".to_string(), + )); + } + }; let accepted_ids = match decision { PrepareUploadDecisionV2::Decline => { @@ -408,8 +425,36 @@ pub(crate) async fn cancel( ) -> Result, AppError> { let v2 = require_v2(&state)?; let query = parse_query(req.uri().query()); + let session_id = query.get("sessionId"); - if let Some(session_id) = query.get("sessionId") { + // A pending prepare-upload request: the sender does not know the session + // ID yet (it is part of the response), so a cancel from the pending + // sender's address is accepted without one. + let pending_cancelled = { + let slot = v2.session.lock().await; + match slot.as_ref() { + Some(SessionStateV2::Pending(pending)) + if pending.sender_ip == client_info.ip + && session_id.is_none_or(|id| *id == pending.session_id) => + { + tracing::info!( + "Pending upload session cancelled by sender: {}", + pending.session_id + ); + // The waiting prepare-upload handler frees the slot and + // notifies the application. + pending.cancel.cancel(); + true + } + _ => false, + } + }; + + if pending_cancelled { + return Ok(Response::new(empty_body())); + } + + if let Some(session_id) = session_id { let cancelled = { let mut slot = v2.session.lock().await; match slot.as_ref() { @@ -515,7 +560,7 @@ impl Drop for PendingSessionGuard { async fn clear_pending_session(v2: &V2State) { let mut slot = v2.session.lock().await; - if matches!(*slot, Some(SessionStateV2::Pending)) { + if matches!(*slot, Some(SessionStateV2::Pending(_))) { *slot = None; } } diff --git a/packages/core/tests/v2_server.rs b/packages/core/tests/v2_server.rs index 449b95aa..19157253 100644 --- a/packages/core/tests/v2_server.rs +++ b/packages/core/tests/v2_server.rs @@ -310,6 +310,7 @@ async fn test_full_upload_flow() { None, prepare_upload_request(&[file_a.clone(), file_b.clone()]), None, + CancellationToken::new(), ) .await .unwrap(); @@ -386,6 +387,7 @@ async fn test_upload_with_matching_sha256() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap() @@ -423,6 +425,7 @@ async fn test_upload_with_mismatched_sha256() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap() @@ -459,6 +462,7 @@ async fn test_upload_retry_after_mismatched_sha256() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap() @@ -522,6 +526,7 @@ async fn test_upload_retry_reuses_the_same_path() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap() @@ -583,6 +588,7 @@ async fn test_upload_mismatched_sha256_attempts_exhausted() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap() @@ -639,6 +645,7 @@ async fn test_upload_saved_to_path_by_server() { None, prepare_upload_request(&[file_a.clone(), file_b.clone()]), None, + CancellationToken::new(), ) .await .unwrap(); @@ -699,6 +706,7 @@ async fn test_upload_with_invalid_token() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap() @@ -759,6 +767,7 @@ async fn test_second_session_blocked_and_cancel() { None, prepare_upload_request(&[file.clone()]), None, + CancellationToken::new(), ) .await .unwrap() @@ -774,6 +783,7 @@ async fn test_second_session_blocked_and_cancel() { None, prepare_upload_request(&[file.clone()]), None, + CancellationToken::new(), ) .await; assert_status(result, 409); @@ -804,11 +814,365 @@ async fn test_second_session_blocked_and_cancel() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await .unwrap(); } +/// The sender aborts the prepare-upload request (drops the connection) while +/// the receiving application is still deciding. The application must be told +/// via [ServerEventV2::PrepareUploadAborted] and the session slot must be +/// freed so the next request is not blocked. +#[tokio::test] +async fn test_prepare_upload_aborted_by_sender_disconnect() { + let _ = tracing_subscriber::fmt().with_test_writer().try_init(); + let port = free_port(); + + let (event_tx, mut event_rx) = mpsc::channel::(16); + let (aborted_tx, aborted_rx) = oneshot::channel::(); + + // 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::(16); + let (aborted_tx, mut aborted_rx) = oneshot::channel::(); + + 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::(16); + let (aborted_tx, aborted_rx) = oneshot::channel::(); + + tokio::spawn(async move { + let mut held_decision = None; + let mut aborted_tx = Some(aborted_tx); + while let Some(event) = event_rx.recv().await { + match event { + ServerEventV2::PrepareUpload { decision_tx, .. } => { + if held_decision.is_none() { + held_decision = Some(decision_tx); + } else { + let _ = decision_tx.send(PrepareUploadDecisionV2::Decline); + } + } + ServerEventV2::PrepareUploadAborted { session_id } => { + if let Some(tx) = aborted_tx.take() { + let _ = tx.send(session_id); + } + } + _ => {} + } + } + }); + + let (stop_tx, stop_rx) = oneshot::channel::<()>(); + start_with_port( + port, + Some(localsend::http::server::TlsConfig { + cert: server_cert.pem(), + private_key: server_key.serialize_pem(), + }), + ClientInfo { + alias: "Test Server".to_string(), + version: "2.1".to_string(), + device_model: Some("Rust".to_string()), + device_type: None, + token: "server-fingerprint".to_string(), + }, + None, + Some(ServerConfigV2 { + pin: None, + event_tx, + }), + None, + stop_rx, + ) + .await + .expect("Failed to start server"); + wait_until_reachable(port).await; + + // The sender cancels while the server is still waiting for the + // application's decision. + let client = + LsHttpClientV2::try_new(&sender_key.serialize_pem(), &sender_cert.pem(), None, None) + .unwrap(); + let cancel = CancellationToken::new(); + tokio::spawn({ + let cancel = cancel.clone(); + async move { + tokio::time::sleep(Duration::from_millis(300)).await; + cancel.cancel(); + } + }); + let result = client + .prepare_upload( + ProtocolType::Https, + "127.0.0.1", + port, + None, + prepare_upload_request(&[file_dto("file-a", "a.bin", 5)]), + None, + cancel, + ) + .await; + assert!( + matches!(result, Err(ClientError::Cancelled)), + "expected ClientError::Cancelled, got {:?}", + result.err() + ); + + tokio::time::timeout(Duration::from_secs(3), aborted_rx) + .await + .expect("PrepareUploadAborted was not emitted after the sender disconnected (TLS)") + .unwrap(); + + // The pending slot must be free again for the next sender. + let client = + LsHttpClientV2::try_new(&sender_key.serialize_pem(), &sender_cert.pem(), None, None) + .unwrap(); + let result = client + .prepare_upload( + ProtocolType::Https, + "127.0.0.1", + port, + None, + prepare_upload_request(&[file_dto("file-b", "b.bin", 5)]), + None, + CancellationToken::new(), + ) + .await; + assert_status(result, 403); + + let _ = stop_tx.send(()); +} + #[tokio::test] async fn test_prepare_upload_declined() { let server = start_test_server(None, false, None).await; @@ -823,6 +1187,7 @@ async fn test_prepare_upload_declined() { None, prepare_upload_request(&[file.clone()]), None, + CancellationToken::new(), ) .await; assert_status(result, 403); @@ -836,6 +1201,7 @@ async fn test_prepare_upload_declined() { None, prepare_upload_request(&[file]), None, + CancellationToken::new(), ) .await; assert_status(result, 403); @@ -857,6 +1223,7 @@ async fn test_pin() { None, prepare_upload_request(&[file.clone()]), None, + CancellationToken::new(), ) .await; assert_status(result, 401); @@ -870,6 +1237,7 @@ async fn test_pin() { None, prepare_upload_request(&[file.clone()]), Some("000000"), + CancellationToken::new(), ) .await; assert_status(result, 401); @@ -883,6 +1251,7 @@ async fn test_pin() { None, prepare_upload_request(&[file]), Some("123456"), + CancellationToken::new(), ) .await .unwrap(); @@ -904,6 +1273,7 @@ async fn test_pin_too_many_attempts() { None, prepare_upload_request(&[file.clone()]), Some("000000"), + CancellationToken::new(), ) .await; assert_status(result, 401); @@ -918,6 +1288,7 @@ async fn test_pin_too_many_attempts() { None, prepare_upload_request(&[file]), Some("123456"), + CancellationToken::new(), ) .await; assert_status(result, 429); diff --git a/packages/core/tests/v2_tls_pinning.rs b/packages/core/tests/v2_tls_pinning.rs index b37bab40..007b3ebc 100644 --- a/packages/core/tests/v2_tls_pinning.rs +++ b/packages/core/tests/v2_tls_pinning.rs @@ -274,6 +274,7 @@ async fn test_transfer_with_matching_fingerprint() { None, prepare_upload_request(&sender, &files), None, + CancellationToken::new(), ) .await .expect("prepare-upload should succeed"); @@ -321,6 +322,7 @@ async fn test_prepare_upload_rejected_on_fingerprint_mismatch() { None, prepare_upload_request(&sender, &files), None, + CancellationToken::new(), ) .await; diff --git a/packages/localsend_isolates/lib/rust/api/http.dart b/packages/localsend_isolates/lib/rust/api/http.dart index 20f2ba2f..0445f0ba 100644 --- a/packages/localsend_isolates/lib/rust/api/http.dart +++ b/packages/localsend_isolates/lib/rust/api/http.dart @@ -12,7 +12,7 @@ import 'package:localsend_isolates/rust/frb_generated.dart'; part 'http.freezed.dart'; -// These functions are ignored because they are not marked as `pub`: `resolve_file_content` +// These functions are ignored because they are not marked as `pub`: `error_chain`, `resolve_file_content` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `from` /// Creates an HTTP client. @@ -46,6 +46,7 @@ abstract class RsHttpClient implements RustOpaqueInterface { required PrepareUploadRequestDto payload, String? publicKey, String? pin, + required RsCancellationToken cancelToken, }); Future register({ diff --git a/packages/localsend_isolates/lib/rust/api/server.dart b/packages/localsend_isolates/lib/rust/api/server.dart index 9f6365c3..ef799597 100644 --- a/packages/localsend_isolates/lib/rust/api/server.dart +++ b/packages/localsend_isolates/lib/rust/api/server.dart @@ -53,11 +53,25 @@ abstract class RsHttpServer implements RustOpaqueInterface { /// transfer on the receiving side. /// /// Uploads that are already in progress still run to completion, but new - /// upload requests are rejected and a new session can be created. + /// upload requests fail and a new session can be created. /// No [RsServerEvent::SessionEnd] is emitted: the application initiated /// the cancellation itself. Future 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 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 failFileUpload({required String sessionId, required String fileId}); + /// Emits server events until the server is stopped. /// Can only be listened to once. /// @@ -65,20 +79,6 @@ abstract class RsHttpServer implements RustOpaqueInterface { /// events are all emitted on the same stream. Stream 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 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 rejectFileUpload({required String sessionId, required String fileId}); - /// Answers the pending [RsServerEvent::WebFileDownload] event with the source /// the file content should be read from (either a path or a file descriptor). /// diff --git a/packages/localsend_isolates/lib/rust/frb_generated.dart b/packages/localsend_isolates/lib/rust/frb_generated.dart index df4ac0db..602ab249 100644 --- a/packages/localsend_isolates/lib/rust/frb_generated.dart +++ b/packages/localsend_isolates/lib/rust/frb_generated.dart @@ -74,7 +74,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => -2071906741; + int get rustContentHash => 1795427439; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( stem: 'rust_lib_localsend_app', @@ -128,6 +128,7 @@ abstract class RustLibApi extends BaseApi { required PrepareUploadRequestDto payload, String? publicKey, String? pin, + required RsCancellationToken cancelToken, }); Future crateApiHttpRsHttpClientRegister({ @@ -156,12 +157,12 @@ abstract class RustLibApi extends BaseApi { Future crateApiServerRsHttpServerCancelSession({required RsHttpServer that, required String sessionId}); + Future crateApiServerRsHttpServerFailFileDownload({required RsHttpServer that, required String sessionId, required String fileId}); + + Future crateApiServerRsHttpServerFailFileUpload({required RsHttpServer that, required String sessionId, required String fileId}); + Stream crateApiServerRsHttpServerListen({required RsHttpServer that}); - Future crateApiServerRsHttpServerRejectFileDownload({required RsHttpServer that, required String sessionId, required String fileId}); - - Future crateApiServerRsHttpServerRejectFileUpload({required RsHttpServer that, required String sessionId, required String fileId}); - Future crateApiServerRsHttpServerRespondFileDownload({ required RsHttpServer that, required String sessionId, @@ -574,6 +575,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { required PrepareUploadRequestDto payload, String? publicKey, String? pin, + required RsCancellationToken cancelToken, }) { return handler.executeNormal( NormalTask( @@ -586,6 +588,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_box_autoadd_prepare_upload_request_dto(payload, serializer); sse_encode_opt_String(publicKey, serializer); sse_encode_opt_String(pin, serializer); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsCancellationToken(cancelToken, serializer); pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 8, port: port_); }, codec: SseCodec( @@ -593,7 +596,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { decodeErrorData: sse_decode_rs_http_client_error, ), constMeta: kCrateApiHttpRsHttpClientPrepareUploadConstMeta, - argValues: [that, protocol, ip, port, payload, publicKey, pin], + argValues: [that, protocol, ip, port, payload, publicKey, pin, cancelToken], apiImpl: this, ), ); @@ -601,7 +604,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiHttpRsHttpClientPrepareUploadConstMeta => const TaskConstMeta( debugName: 'RsHttpClient_prepare_upload', - argNames: ['that', 'protocol', 'ip', 'port', 'payload', 'publicKey', 'pin'], + argNames: ['that', 'protocol', 'ip', 'port', 'payload', 'publicKey', 'pin', 'cancelToken'], ); @override @@ -739,6 +742,60 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that', 'sessionId'], ); + @override + Future 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 crateApiServerRsHttpServerFailFileUpload({required RsHttpServer that, required String sessionId, required String fileId}) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); + sse_encode_String(sessionId, serializer); + sse_encode_String(fileId, serializer); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13, port: port_); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiServerRsHttpServerFailFileUploadConstMeta, + argValues: [that, sessionId, fileId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiServerRsHttpServerFailFileUploadConstMeta => const TaskConstMeta( + debugName: 'RsHttpServer_fail_file_upload', + argNames: ['that', 'sessionId', 'fileId'], + ); + @override Stream crateApiServerRsHttpServerListen({required RsHttpServer that}) { final sink = RustStreamSink(); @@ -749,7 +806,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); sse_encode_StreamSink_rs_server_event_Sse(sink, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12, port: port_); + pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14, port: port_); }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -769,60 +826,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ['that', 'sink'], ); - @override - Future 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 crateApiServerRsHttpServerRejectFileUpload({required RsHttpServer that, required String sessionId, required String fileId}) { - return handler.executeNormal( - NormalTask( - callFfi: (port_) { - final serializer = SseSerializer(generalizedFrbRustBinding); - sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerRsHttpServer(that, serializer); - sse_encode_String(sessionId, serializer); - sse_encode_String(fileId, serializer); - pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14, port: port_); - }, - codec: SseCodec( - decodeSuccessData: sse_decode_unit, - decodeErrorData: null, - ), - constMeta: kCrateApiServerRsHttpServerRejectFileUploadConstMeta, - argValues: [that, sessionId, fileId], - apiImpl: this, - ), - ); - } - - TaskConstMeta get kCrateApiServerRsHttpServerRejectFileUploadConstMeta => const TaskConstMeta( - debugName: 'RsHttpServer_reject_file_upload', - argNames: ['that', 'sessionId', 'fileId'], - ); - @override Future crateApiServerRsHttpServerRespondFileDownload({ required RsHttpServer that, @@ -5573,6 +5576,7 @@ class RsHttpClientImpl extends RustOpaque implements RsHttpClient { required PrepareUploadRequestDto payload, String? publicKey, String? pin, + required RsCancellationToken cancelToken, }) => RustLib.instance.api.crateApiHttpRsHttpClientPrepareUpload( that: this, protocol: protocol, @@ -5581,6 +5585,7 @@ class RsHttpClientImpl extends RustOpaque implements RsHttpClient { payload: payload, publicKey: publicKey, pin: pin, + cancelToken: cancelToken, ); Future register({ @@ -5638,12 +5643,28 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { /// transfer on the receiving side. /// /// Uploads that are already in progress still run to completion, but new - /// upload requests are rejected and a new session can be created. + /// upload requests fail and a new session can be created. /// No [RsServerEvent::SessionEnd] is emitted: the application initiated /// the cancellation itself. Future cancelSession({required String sessionId}) => RustLib.instance.api.crateApiServerRsHttpServerCancelSession(that: this, sessionId: sessionId); + /// Fails the pending [RsServerEvent::WebFileDownload] event, e.g. because + /// the application failed to resolve a source for the file content. + /// + /// The download request fails with an error response. + /// Does nothing if the download was already answered. + Future 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 failFileUpload({required String sessionId, required String fileId}) => + RustLib.instance.api.crateApiServerRsHttpServerFailFileUpload(that: this, sessionId: sessionId, fileId: fileId); + /// Emits server events until the server is stopped. /// Can only be listened to once. /// @@ -5653,22 +5674,6 @@ class RsHttpServerImpl extends RustOpaque implements RsHttpServer { that: this, ); - /// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because - /// the application failed to resolve a source for the file content. - /// - /// The download request fails with an error response. - /// Does nothing if the download was already answered. - Future 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 rejectFileUpload({required String sessionId, required String fileId}) => - RustLib.instance.api.crateApiServerRsHttpServerRejectFileUpload(that: this, sessionId: sessionId, fileId: fileId); - /// Answers the pending [RsServerEvent::WebFileDownload] event with the source /// the file content should be read from (either a path or a file descriptor). /// diff --git a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart index ec497b71..67e9c1eb 100644 --- a/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart +++ b/packages/localsend_isolates/lib/src/isolate/child/server_isolate.dart @@ -144,15 +144,15 @@ class HttpServerFileDownloadTargetTask implements BaseHttpServerTask { }); } -/// Rejects a pending [HttpServerWebFileDownloadEvent], e.g. because no source +/// Fails a pending [HttpServerWebFileDownloadEvent], e.g. because no source /// for the file content could be resolved. The download request fails with an /// error response. Does nothing if the download was already answered with a /// [HttpServerFileDownloadTargetTask]. -class HttpServerRejectFileDownloadTask implements BaseHttpServerTask { +class HttpServerFailFileDownloadTask implements BaseHttpServerTask { final String sessionId; final String fileId; - HttpServerRejectFileDownloadTask({ + HttpServerFailFileDownloadTask({ required this.sessionId, required this.fileId, }); @@ -575,12 +575,12 @@ Future setupHttpServerIsolate( fileDescriptor: targetTask.fileDescriptor, ); return; - case HttpServerRejectFileDownloadTask rejectTask: + case HttpServerFailFileDownloadTask failTask: await ref .read(httpServerProvider) - .rejectFileDownload( - sessionId: rejectTask.sessionId, - fileId: rejectTask.fileId, + .failFileDownload( + sessionId: failTask.sessionId, + fileId: failTask.fileId, ); return; } @@ -642,12 +642,12 @@ Future _handleFileUpload({ } catch (e, st) { _logger.severe('Failed to prepare save target', e, st); - // The Rust server is still waiting for the target; rejecting fails the + // The Rust server is still waiting for the target; failing it ends the // sender's request which would otherwise hang forever. try { - await ref.read(httpServerProvider).rejectFileUpload(sessionId: sessionId, fileId: fileId); + await ref.read(httpServerProvider).failFileUpload(sessionId: sessionId, fileId: fileId); } catch (e) { - _logger.warning('Failed to reject file upload', e); + _logger.warning('Could not fail the pending file upload', e); } emitFailed(e); diff --git a/packages/localsend_isolates/lib/src/isolate/parent/actions.dart b/packages/localsend_isolates/lib/src/isolate/parent/actions.dart index 975289e1..7d1d0f61 100644 --- a/packages/localsend_isolates/lib/src/isolate/parent/actions.dart +++ b/packages/localsend_isolates/lib/src/isolate/parent/actions.dart @@ -391,16 +391,16 @@ class IsolateHttpServerFileDownloadTargetAction extends ReduxAction { +class IsolateHttpServerFailFileDownloadAction extends ReduxAction { final String sessionId; final String fileId; - IsolateHttpServerRejectFileDownloadAction({ + IsolateHttpServerFailFileDownloadAction({ required this.sessionId, required this.fileId, }); @@ -416,7 +416,7 @@ class IsolateHttpServerRejectFileDownloadAction extends ReduxAction rejectFileUpload({required String sessionId, required String fileId}) async { - await _requireServer().rejectFileUpload(sessionId: sessionId, fileId: fileId); + Future failFileUpload({required String sessionId, required String fileId}) async { + await _requireServer().failFileUpload(sessionId: sessionId, fileId: fileId); } /// Cancels the active upload session. Uploads that are already in progress - /// still run to completion, but new upload requests are rejected and a new + /// still run to completion, but new upload requests fail and a new /// session can be created. No session-end event is emitted. Future cancelSession({required String sessionId}) async { await _requireServer().cancelSession(sessionId: sessionId); @@ -110,11 +110,11 @@ class HttpServerService { ); } - /// Rejects a pending web file download, e.g. because no content source could + /// Fails a pending web file download, e.g. because no content source could /// be resolved. The download request fails with an error response. /// Does nothing if the download was already answered via [respondFileDownload]. - Future rejectFileDownload({required String sessionId, required String fileId}) async { - await _requireServer().rejectFileDownload(sessionId: sessionId, fileId: fileId); + Future failFileDownload({required String sessionId, required String fileId}) async { + await _requireServer().failFileDownload(sessionId: sessionId, fileId: fileId); } /// Stops the server. The event stream returned by [start] will end. diff --git a/packages/localsend_isolates/rust/src/api/http.rs b/packages/localsend_isolates/rust/src/api/http.rs index 3f546176..00a0f907 100644 --- a/packages/localsend_isolates/rust/src/api/http.rs +++ b/packages/localsend_isolates/rust/src/api/http.rs @@ -67,10 +67,19 @@ impl RsHttpClient { payload: PrepareUploadRequestDto, public_key: Option, pin: Option, + cancel_token: &RsCancellationToken, ) -> Result { let response = self .inner - .prepare_upload(protocol, ip, port, public_key, payload, pin.as_deref()) + .prepare_upload( + protocol, + ip, + port, + public_key, + payload, + pin.as_deref(), + cancel_token.inner.clone(), + ) .await .map_err(RsHttpClientError::from)?; @@ -208,7 +217,7 @@ impl From for RsHttpClientError { /// Renders an error together with everything that caused it. /// /// [`reqwest::Error`] alone only says "error sending request for url (...)". -pub fn error_chain(e: &dyn std::error::Error) -> String { +pub(crate) fn error_chain(e: &dyn std::error::Error) -> String { use std::fmt::Write; let mut message = e.to_string(); diff --git a/packages/localsend_isolates/rust/src/api/server.rs b/packages/localsend_isolates/rust/src/api/server.rs index c34d4e09..1ab02c23 100644 --- a/packages/localsend_isolates/rust/src/api/server.rs +++ b/packages/localsend_isolates/rust/src/api/server.rs @@ -443,12 +443,12 @@ impl RsHttpServer { } } - /// Rejects the pending [RsServerEvent::FileUpload] event, e.g. because + /// Fails the pending [RsServerEvent::FileUpload] event, e.g. because /// the application failed to prepare a save target for the file. /// /// The upload request fails with an error response and the file is marked /// as failed. Does nothing if the upload was already answered. - pub async fn reject_file_upload(&self, session_id: String, file_id: String) { + pub async fn fail_file_upload(&self, session_id: String, file_id: String) { // Dropping the responder fails the request waiting for the target. self.pending_uploads .lock() @@ -509,12 +509,12 @@ impl RsHttpServer { Ok(()) } - /// Rejects the pending [RsServerEvent::WebFileDownload] event, e.g. because + /// Fails the pending [RsServerEvent::WebFileDownload] event, e.g. because /// the application failed to resolve a source for the file content. /// /// The download request fails with an error response. /// Does nothing if the download was already answered. - pub async fn reject_file_download(&self, session_id: String, file_id: String) { + pub async fn fail_file_download(&self, session_id: String, file_id: String) { // Dropping the responder fails the request waiting for the content. self.pending_downloads .lock() @@ -526,7 +526,7 @@ impl RsHttpServer { /// transfer on the receiving side. /// /// Uploads that are already in progress still run to completion, but new - /// upload requests are rejected and a new session can be created. + /// upload requests fail and a new session can be created. /// No [RsServerEvent::SessionEnd] is emitted: the application initiated /// the cancellation itself. pub async fn cancel_session(&self, session_id: String) { diff --git a/packages/localsend_isolates/rust/src/frb_generated.rs b/packages/localsend_isolates/rust/src/frb_generated.rs index aafd1a18..969d05fb 100644 --- a/packages/localsend_isolates/rust/src/frb_generated.rs +++ b/packages/localsend_isolates/rust/src/frb_generated.rs @@ -44,7 +44,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -2071906741; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1795427439; // Section: executor @@ -506,16 +506,27 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl( ::sse_decode(&mut deserializer); let api_public_key = >::sse_decode(&mut deserializer); let api_pin = >::sse_decode(&mut deserializer); + let api_cancel_token = , + >>::sse_decode(&mut deserializer); deserializer.end(); move |context| async move { transform_result_sse::<_, crate::api::http::RsHttpClientError>( (move || async move { let mut api_that_guard = None; + let mut api_cancel_token_guard = None; let decode_indices_ = flutter_rust_bridge::for_generated::lockable_compute_decode_order( - vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( - &api_that, 0, false, - )], + vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_cancel_token, + 1, + false, + ), + ], ); for i in decode_indices_ { match i { @@ -523,10 +534,15 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl( api_that_guard = Some(api_that.lockable_decode_async_ref().await) } + 1 => { + api_cancel_token_guard = + Some(api_cancel_token.lockable_decode_async_ref().await) + } _ => unreachable!(), } } let api_that_guard = api_that_guard.unwrap(); + let api_cancel_token_guard = api_cancel_token_guard.unwrap(); let output_ok = crate::api::http::RsHttpClient::prepare_upload( &*api_that_guard, api_protocol, @@ -535,6 +551,7 @@ fn wire__crate__api__http__RsHttpClient_prepare_upload_impl( api_payload, api_public_key, api_pin, + &*api_cancel_token_guard, ) .await?; Ok(output_ok) @@ -775,6 +792,134 @@ fn wire__crate__api__server__RsHttpServer_cancel_session_impl( }, ) } +fn wire__crate__api__server__RsHttpServer_fail_file_download_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::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 = , + >>::sse_decode(&mut deserializer); + let api_session_id = ::sse_decode(&mut deserializer); + let api_file_id = ::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::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 = , + >>::sse_decode(&mut deserializer); + let api_session_id = ::sse_decode(&mut deserializer); + let api_file_id = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, ()>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::api::server::RsHttpServer::fail_file_upload( + &*api_that_guard, + api_session_id, + api_file_id, + ) + .await; + })?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__server__RsHttpServer_listen_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -837,134 +982,6 @@ fn wire__crate__api__server__RsHttpServer_listen_impl( }, ) } -fn wire__crate__api__server__RsHttpServer_reject_file_download_impl( - port_: flutter_rust_bridge::for_generated::MessagePort, - ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, - rust_vec_len_: i32, - data_len_: i32, -) { - FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( - flutter_rust_bridge::for_generated::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 = , - >>::sse_decode(&mut deserializer); - let api_session_id = ::sse_decode(&mut deserializer); - let api_file_id = ::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::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 = , - >>::sse_decode(&mut deserializer); - let api_session_id = ::sse_decode(&mut deserializer); - let api_file_id = ::sse_decode(&mut deserializer); - deserializer.end(); - move |context| async move { - transform_result_sse::<_, ()>( - (move || async move { - let mut api_that_guard = None; - let decode_indices_ = - flutter_rust_bridge::for_generated::lockable_compute_decode_order( - vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( - &api_that, 0, false, - )], - ); - for i in decode_indices_ { - match i { - 0 => { - api_that_guard = - Some(api_that.lockable_decode_async_ref().await) - } - _ => unreachable!(), - } - } - let api_that_guard = api_that_guard.unwrap(); - let output_ok = Result::<_, ()>::Ok({ - crate::api::server::RsHttpServer::reject_file_upload( - &*api_that_guard, - api_session_id, - api_file_id, - ) - .await; - })?; - Ok(output_ok) - })() - .await, - ) - } - }, - ) -} fn wire__crate__api__server__RsHttpServer_respond_file_download_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4460,19 +4477,19 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 12 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len), - 13 => wire__crate__api__server__RsHttpServer_reject_file_download_impl( + 12 => wire__crate__api__server__RsHttpServer_fail_file_download_impl( port, ptr, rust_vec_len, data_len, ), - 14 => wire__crate__api__server__RsHttpServer_reject_file_upload_impl( + 13 => wire__crate__api__server__RsHttpServer_fail_file_upload_impl( port, ptr, rust_vec_len, data_len, ), + 14 => wire__crate__api__server__RsHttpServer_listen_impl(port, ptr, rust_vec_len, data_len), 15 => wire__crate__api__server__RsHttpServer_respond_file_download_impl( port, ptr,