feat: add http server FFI bindings
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-07-17 00:16:39 +02:00
parent 22d6f2b713
commit f813d4a90d
11 changed files with 9144 additions and 2546 deletions
+1
View File
@@ -1,3 +1,4 @@
rust_input: crate::api
rust_root: rust/
dart_output: lib/rust
dart_format_line_length: 150
+2
View File
@@ -1,3 +1,5 @@
export 'package:localsend_app/isolate/src/isolate/child/http_server_events.dart';
export 'package:localsend_app/isolate/src/isolate/child/http_server_isolate.dart';
export 'package:localsend_app/isolate/src/isolate/child/sync_provider.dart';
export 'package:localsend_app/isolate/src/isolate/child/upload_isolate.dart'
show
@@ -0,0 +1,123 @@
import 'dart:typed_data';
import 'package:localsend_app/isolate/model/dto/file_dto.dart';
import 'package:localsend_app/isolate/model/dto/register_dto.dart';
sealed class HttpServerEvent {
const HttpServerEvent();
}
class HttpServerStartedEvent extends HttpServerEvent {
const HttpServerStartedEvent();
}
class HttpServerStoppedEvent extends HttpServerEvent {
const HttpServerStoppedEvent();
}
class HttpServerErrorEvent extends HttpServerEvent {
final String error;
const HttpServerErrorEvent({required this.error});
}
class HttpServerShowEvent extends HttpServerEvent {
final List<String> args;
const HttpServerShowEvent({required this.args});
}
class HttpServerRegisterEvent extends HttpServerEvent {
final String ip;
final RegisterDto info;
const HttpServerRegisterEvent({required this.ip, required this.info});
}
class HttpServerPrepareUploadEvent extends HttpServerEvent {
final int requestId;
final String ip;
final RegisterDto info;
final Map<String, FileDto> files;
const HttpServerPrepareUploadEvent({
required this.requestId,
required this.ip,
required this.info,
required this.files,
});
}
class HttpServerFileUploadEvent extends HttpServerEvent {
final int requestId;
final String sessionId;
final String fileId;
final FileDto file;
const HttpServerFileUploadEvent({
required this.requestId,
required this.sessionId,
required this.fileId,
required this.file,
});
}
class HttpServerFileUploadChunkEvent extends HttpServerEvent {
final int requestId;
final Uint8List data;
const HttpServerFileUploadChunkEvent({
required this.requestId,
required this.data,
});
}
class HttpServerFileUploadFinishedEvent extends HttpServerEvent {
final int requestId;
final String? error;
const HttpServerFileUploadFinishedEvent({
required this.requestId,
required this.error,
});
}
enum HttpServerSessionEndReason { finished, cancelled }
class HttpServerSessionEndEvent extends HttpServerEvent {
final String sessionId;
final HttpServerSessionEndReason reason;
const HttpServerSessionEndEvent({
required this.sessionId,
required this.reason,
});
}
class HttpServerPrepareDownloadEvent extends HttpServerEvent {
final int requestId;
final String ip;
final String sessionId;
final String? userAgent;
const HttpServerPrepareDownloadEvent({
required this.requestId,
required this.ip,
required this.sessionId,
required this.userAgent,
});
}
class HttpServerFileDownloadEvent extends HttpServerEvent {
final int requestId;
final String sessionId;
final String fileId;
final FileDto file;
const HttpServerFileDownloadEvent({
required this.requestId,
required this.sessionId,
required this.fileId,
required this.file,
});
}
@@ -0,0 +1,513 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:localsend_app/isolate/constants.dart';
import 'package:localsend_app/isolate/model/device.dart' as app;
import 'package:localsend_app/isolate/model/dto/file_dto.dart' as app;
import 'package:localsend_app/isolate/model/dto/multicast_dto.dart' as app;
import 'package:localsend_app/isolate/model/dto/register_dto.dart' as app;
import 'package:localsend_app/isolate/model/stored_security_context.dart';
import 'package:localsend_app/isolate/src/isolate/child/http_server_events.dart';
import 'package:localsend_app/isolate/src/isolate/child/main.dart';
import 'package:localsend_app/isolate/src/isolate/dto/send_to_isolate_data.dart';
import 'package:localsend_app/rust/api/http_server.dart' as rust;
import 'package:localsend_app/rust/api/model.dart' as rust;
import 'package:localsend_app/rust/api/stream.dart';
sealed class HttpServerTask {
const HttpServerTask();
}
class HttpServerStartTask extends HttpServerTask {
final String alias;
final int port;
final bool https;
final String? deviceModel;
final app.DeviceType deviceType;
final String fingerprint;
final StoredSecurityContext securityContext;
final String? showToken;
final String? receivePin;
final HttpServerWebSendConfig? webSend;
const HttpServerStartTask({
required this.alias,
required this.port,
required this.https,
required this.deviceModel,
required this.deviceType,
required this.fingerprint,
required this.securityContext,
required this.showToken,
required this.receivePin,
required this.webSend,
});
}
class HttpServerWebSendConfig {
final Map<String, app.FileDto> files;
final String? pin;
final HttpServerWebSendI18n i18n;
const HttpServerWebSendConfig({
required this.files,
required this.pin,
required this.i18n,
});
}
class HttpServerWebSendI18n {
final String waiting;
final String enterPin;
final String invalidPin;
final String tooManyAttempts;
final String rejected;
final String files;
final String fileName;
final String size;
const HttpServerWebSendI18n({
required this.waiting,
required this.enterPin,
required this.invalidPin,
required this.tooManyAttempts,
required this.rejected,
required this.files,
required this.fileName,
required this.size,
});
}
class HttpServerStopTask extends HttpServerTask {
const HttpServerStopTask();
}
class HttpServerRespondPrepareUploadTask extends HttpServerTask {
final int requestId;
final Set<String>? fileIds;
const HttpServerRespondPrepareUploadTask({
required this.requestId,
required this.fileIds,
});
}
sealed class HttpServerFileUploadTarget {
const HttpServerFileUploadTarget();
}
class HttpServerFileUploadPathTarget extends HttpServerFileUploadTarget {
final String path;
const HttpServerFileUploadPathTarget({required this.path});
}
class HttpServerFileUploadDescriptorTarget extends HttpServerFileUploadTarget {
final int fileDescriptor;
const HttpServerFileUploadDescriptorTarget({required this.fileDescriptor});
}
class HttpServerFileUploadStreamTarget extends HttpServerFileUploadTarget {
const HttpServerFileUploadStreamTarget();
}
class HttpServerSetFileUploadTargetTask extends HttpServerTask {
final int requestId;
final HttpServerFileUploadTarget target;
const HttpServerSetFileUploadTargetTask({
required this.requestId,
required this.target,
});
}
class HttpServerRespondPrepareDownloadTask extends HttpServerTask {
final int requestId;
final bool accepted;
const HttpServerRespondPrepareDownloadTask({
required this.requestId,
required this.accepted,
});
}
sealed class HttpServerFileDownloadContent {
const HttpServerFileDownloadContent();
}
class HttpServerFileDownloadPathContent extends HttpServerFileDownloadContent {
final String path;
const HttpServerFileDownloadPathContent({required this.path});
}
class HttpServerFileDownloadDescriptorContent extends HttpServerFileDownloadContent {
final int fileDescriptor;
const HttpServerFileDownloadDescriptorContent({required this.fileDescriptor});
}
class HttpServerFileDownloadBytesContent extends HttpServerFileDownloadContent {
final Uint8List bytes;
const HttpServerFileDownloadBytesContent({required this.bytes});
}
class HttpServerFileDownloadStreamContent extends HttpServerFileDownloadContent {
final Stream<List<int>> stream;
const HttpServerFileDownloadStreamContent({required this.stream});
}
class HttpServerSetFileDownloadContentTask extends HttpServerTask {
final int requestId;
final HttpServerFileDownloadContent content;
const HttpServerSetFileDownloadContentTask({
required this.requestId,
required this.content,
});
}
Future<void> setupHttpServerIsolate(
Stream<SendToIsolateData<HttpServerTask>> receiveFromMain,
void Function(HttpServerEvent) sendToMain,
InitialData initialData,
) async {
final bindings = _HttpServerBindings(sendToMain);
await setupChildIsolateHelper(
debugLabel: 'HttpServerIsolate',
receiveFromMain: receiveFromMain,
sendToMain: sendToMain,
initialData: initialData,
handler: (_, task) => bindings.handle(task),
);
}
class _HttpServerBindings {
final void Function(HttpServerEvent) sendToMain;
final Map<int, rust.RsHttpServerPrepareUploadRequest> _prepareUploads = {};
final Map<int, rust.RsHttpServerFileUploadRequest> _fileUploads = {};
final Map<int, rust.RsHttpServerPrepareDownloadRequest> _prepareDownloads = {};
final Map<int, rust.RsHttpServerFileDownloadRequest> _fileDownloads = {};
rust.RsHttpServer? _server;
StreamSubscription<rust.RsHttpServerEvent>? _eventSubscription;
Future<void>? _startOperation;
int _nextRequestId = 0;
_HttpServerBindings(this.sendToMain);
Future<void> handle(HttpServerTask task) async {
try {
switch (task) {
case HttpServerStartTask():
if (_startOperation != null || _server != null) {
sendToMain(const HttpServerErrorEvent(error: 'HTTP server already started'));
return;
}
final operation = _start(task);
_startOperation = operation;
try {
await operation;
} finally {
if (identical(_startOperation, operation)) {
_startOperation = null;
}
}
case HttpServerStopTask():
await _startOperation;
await _stop();
case HttpServerRespondPrepareUploadTask():
await _respondPrepareUpload(task);
case HttpServerSetFileUploadTargetTask():
await _setFileUploadTarget(task);
case HttpServerRespondPrepareDownloadTask():
await _respondPrepareDownload(task);
case HttpServerSetFileDownloadContentTask():
await _setFileDownloadContent(task);
}
} catch (error) {
sendToMain(HttpServerErrorEvent(error: error.toString()));
}
}
Future<void> _start(HttpServerStartTask task) async {
final server = rust.createHttpServer();
_server = server;
_eventSubscription = server.listen().listen(
_handleEvent,
onError: (Object error, StackTrace stackTrace) {
sendToMain(HttpServerErrorEvent(error: error.toString()));
},
);
try {
await server.start(
port: task.port,
tls: task.https
? rust.RsHttpServerTlsConfig(
cert: task.securityContext.certificate,
privateKey: task.securityContext.privateKey,
)
: null,
info: rust.RsHttpServerInfo(
alias: task.alias,
version: protocolVersion,
deviceModel: task.deviceModel,
deviceType: rust.DeviceType.values.byName(task.deviceType.name),
token: task.fingerprint,
),
internal: task.showToken == null ? null : rust.RsHttpServerInternalConfig(showToken: task.showToken!),
v2: rust.RsHttpServerV2Config(pin: task.receivePin),
webSend: task.webSend?._toRust(),
);
sendToMain(const HttpServerStartedEvent());
} catch (error) {
await _disposeServer();
sendToMain(HttpServerErrorEvent(error: error.toString()));
}
}
Future<void> _stop() async {
if (_server == null) {
return;
}
await _disposeServer();
sendToMain(const HttpServerStoppedEvent());
}
Future<void> _disposeServer() async {
_server?.stop();
_server = null;
await _eventSubscription?.cancel();
_eventSubscription = null;
_prepareUploads.clear();
_fileUploads.clear();
_prepareDownloads.clear();
_fileDownloads.clear();
}
void _handleEvent(rust.RsHttpServerEvent event) {
switch (event.kind()) {
case rust.RsHttpServerEventKind.show_:
sendToMain(HttpServerShowEvent(args: event.args()!));
case rust.RsHttpServerEventKind.register:
sendToMain(HttpServerRegisterEvent(ip: event.ip()!, info: event.info()!._toApp()));
case rust.RsHttpServerEventKind.prepareUpload:
final requestId = _newRequestId();
_prepareUploads[requestId] = event.takePrepareUploadRequest()!;
sendToMain(
HttpServerPrepareUploadEvent(
requestId: requestId,
ip: event.ip()!,
info: event.info()!._toApp(),
files: event.files()!.map((id, file) => MapEntry(id, file._toApp())),
),
);
case rust.RsHttpServerEventKind.fileUpload:
final requestId = _newRequestId();
_fileUploads[requestId] = event.takeFileUploadRequest()!;
sendToMain(
HttpServerFileUploadEvent(
requestId: requestId,
sessionId: event.sessionId()!,
fileId: event.fileId()!,
file: event.file()!._toApp(),
),
);
case rust.RsHttpServerEventKind.sessionEnd:
sendToMain(
HttpServerSessionEndEvent(
sessionId: event.sessionId()!,
reason: HttpServerSessionEndReason.values.byName(event.reason()!.name),
),
);
case rust.RsHttpServerEventKind.prepareDownload:
final requestId = _newRequestId();
_prepareDownloads[requestId] = event.takePrepareDownloadRequest()!;
sendToMain(
HttpServerPrepareDownloadEvent(
requestId: requestId,
ip: event.ip()!,
sessionId: event.sessionId()!,
userAgent: event.userAgent(),
),
);
case rust.RsHttpServerEventKind.fileDownload:
final requestId = _newRequestId();
_fileDownloads[requestId] = event.takeFileDownloadRequest()!;
sendToMain(
HttpServerFileDownloadEvent(
requestId: requestId,
sessionId: event.sessionId()!,
fileId: event.fileId()!,
file: event.file()!._toApp(),
),
);
}
}
int _newRequestId() => _nextRequestId++;
Future<void> _respondPrepareUpload(HttpServerRespondPrepareUploadTask task) async {
final request = _prepareUploads.remove(task.requestId);
if (request == null) {
return;
}
final fileIds = task.fileIds;
if (fileIds == null) {
await request.decline();
} else {
await request.accept(fileIds: fileIds);
}
}
Future<void> _respondPrepareDownload(HttpServerRespondPrepareDownloadTask task) async {
final request = _prepareDownloads.remove(task.requestId);
if (request == null) {
return;
}
if (task.accepted) {
await request.accept();
} else {
await request.decline();
}
}
Future<void> _setFileUploadTarget(HttpServerSetFileUploadTargetTask task) async {
final request = _fileUploads.remove(task.requestId);
if (request == null) {
return;
}
switch (task.target) {
case HttpServerFileUploadPathTarget(:final path):
await _reportFileUploadResult(task.requestId, request.saveToPath(path: path));
case HttpServerFileUploadDescriptorTarget(:final fileDescriptor):
await _reportFileUploadResult(task.requestId, request.saveToFileDescriptor(fd: fileDescriptor));
case HttpServerFileUploadStreamTarget():
var failed = false;
request.receive().listen(
(data) => sendToMain(HttpServerFileUploadChunkEvent(requestId: task.requestId, data: data)),
onError: (Object error, StackTrace stackTrace) {
failed = true;
sendToMain(HttpServerFileUploadFinishedEvent(requestId: task.requestId, error: error.toString()));
},
onDone: () {
if (!failed) {
sendToMain(HttpServerFileUploadFinishedEvent(requestId: task.requestId, error: null));
}
},
);
}
}
Future<void> _reportFileUploadResult(int requestId, Future<void> result) async {
try {
await result;
sendToMain(HttpServerFileUploadFinishedEvent(requestId: requestId, error: null));
} catch (error) {
sendToMain(HttpServerFileUploadFinishedEvent(requestId: requestId, error: error.toString()));
}
}
Future<void> _setFileDownloadContent(HttpServerSetFileDownloadContentTask task) async {
final request = _fileDownloads.remove(task.requestId);
if (request == null) {
return;
}
switch (task.content) {
case HttpServerFileDownloadPathContent(:final path):
await request.providePath(path: path);
case HttpServerFileDownloadDescriptorContent(:final fileDescriptor):
await request.provideFileDescriptor(fd: fileDescriptor);
case HttpServerFileDownloadBytesContent(:final bytes):
await request.provideBytes(data: bytes);
case HttpServerFileDownloadStreamContent(:final stream):
final (sink, receiver) = await createStream();
await request.provideStream(stream: receiver);
unawaited(
_pipeStream(stream, sink).catchError((Object error) {
sink.close();
}),
);
}
}
}
Future<void> _pipeStream(Stream<List<int>> stream, Dart2RustStreamSink sink) async {
try {
await for (final data in stream) {
await sink.add(data: data);
}
} finally {
sink.close();
}
}
extension on HttpServerWebSendConfig {
rust.RsHttpServerWebSendConfig _toRust() => rust.RsHttpServerWebSendConfig(
files: files.map((id, file) => MapEntry(id, file._toRust())),
pin: pin,
i18N: rust.RsHttpServerWebSendI18n(
waiting: i18n.waiting,
enterPin: i18n.enterPin,
invalidPin: i18n.invalidPin,
tooManyAttempts: i18n.tooManyAttempts,
rejected: i18n.rejected,
files: i18n.files,
fileName: i18n.fileName,
size: i18n.size,
),
);
}
extension on rust.RegisterDto {
app.RegisterDto _toApp() => app.RegisterDto(
alias: alias,
version: version,
deviceModel: deviceModel,
deviceType: deviceType == null ? null : app.DeviceType.values.byName(deviceType!.name),
fingerprint: token,
port: port,
protocol: app.ProtocolType.values.byName(protocol.name),
download: hasWebInterface,
);
}
extension on rust.FileDto {
app.FileDto _toApp() => app.FileDto(
id: id,
fileName: fileName,
size: size.toInt(),
fileType: app.decodeFromMime(fileType),
hash: sha256,
preview: preview,
metadata: metadata == null
? null
: app.FileMetadata(
lastModified: metadata!.modified == null ? null : DateTime.tryParse(metadata!.modified!),
lastAccessed: metadata!.accessed == null ? null : DateTime.tryParse(metadata!.accessed!),
),
);
}
extension on app.FileDto {
rust.FileDto _toRust() => rust.FileDto(
id: id,
fileName: fileName,
size: BigInt.from(size),
fileType: lookupMime(),
sha256: hash,
preview: preview,
metadata: metadata == null
? null
: rust.FileMetadata(
modified: metadata!.lastModified?.toIso8601String(),
accessed: metadata!.lastAccessed?.toIso8601String(),
),
);
}
+254
View File
@@ -0,0 +1,254 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:localsend_app/rust/api/model.dart';
import 'package:localsend_app/rust/api/stream.dart';
import 'package:localsend_app/rust/frb_generated.dart';
// These functions are ignored because they are not marked as `pub`: `flatten_file_result`, `register_dto_from_v2`, `respond`, `respond`, `send_content`, `spawn_internal_event_forwarder`, `spawn_v2_event_forwarder`, `spawn_web_send_event_forwarder`, `take_target`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `drop`, `eq`, `eq`, `fmt`, `fmt`
RsHttpServer createHttpServer() => RustLib.instance.api.crateApiHttpServerCreateHttpServer();
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServer>>
abstract class RsHttpServer implements RustOpaqueInterface {
Stream<RsHttpServerEvent> listen();
Future<void> start({
required int port,
RsHttpServerTlsConfig? tls,
required RsHttpServerInfo info,
RsHttpServerInternalConfig? internal,
RsHttpServerV2Config? v2,
RsHttpServerWebSendConfig? webSend,
});
void stop();
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServerEvent>>
abstract class RsHttpServerEvent implements RustOpaqueInterface {
List<String>? args();
FileDto? file();
String? fileId();
Map<String, FileDto>? files();
RegisterDto? info();
String? ip();
RsHttpServerEventKind kind();
RsHttpServerSessionEndReason? reason();
String? sessionId();
RsHttpServerFileDownloadRequest? takeFileDownloadRequest();
RsHttpServerFileUploadRequest? takeFileUploadRequest();
RsHttpServerPrepareDownloadRequest? takePrepareDownloadRequest();
RsHttpServerPrepareUploadRequest? takePrepareUploadRequest();
String? userAgent();
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServerFileDownloadRequest>>
abstract class RsHttpServerFileDownloadRequest implements RustOpaqueInterface {
Future<void> provideBytes({required List<int> data});
Future<void> provideFileDescriptor({required int fd});
Future<void> providePath({required String path});
Future<void> provideStream({required Dart2RustStreamReceiver stream});
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServerFileUploadRequest>>
abstract class RsHttpServerFileUploadRequest implements RustOpaqueInterface {
Stream<Uint8List> receive();
Future<void> saveToFileDescriptor({required int fd});
Future<void> saveToPath({required String path});
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServerPrepareDownloadRequest>>
abstract class RsHttpServerPrepareDownloadRequest implements RustOpaqueInterface {
Future<void> accept();
Future<void> decline();
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<RsHttpServerPrepareUploadRequest>>
abstract class RsHttpServerPrepareUploadRequest implements RustOpaqueInterface {
Future<void> accept({required Set<String> fileIds});
Future<void> decline();
}
enum RsHttpServerEventKind {
show_,
register,
prepareUpload,
fileUpload,
sessionEnd,
prepareDownload,
fileDownload,
}
class RsHttpServerInfo {
final String alias;
final String version;
final String? deviceModel;
final DeviceType? deviceType;
final String token;
const RsHttpServerInfo({
required this.alias,
required this.version,
this.deviceModel,
this.deviceType,
required this.token,
});
@override
int get hashCode => alias.hashCode ^ version.hashCode ^ deviceModel.hashCode ^ deviceType.hashCode ^ token.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RsHttpServerInfo &&
runtimeType == other.runtimeType &&
alias == other.alias &&
version == other.version &&
deviceModel == other.deviceModel &&
deviceType == other.deviceType &&
token == other.token;
}
class RsHttpServerInternalConfig {
final String showToken;
const RsHttpServerInternalConfig({
required this.showToken,
});
@override
int get hashCode => showToken.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) || other is RsHttpServerInternalConfig && runtimeType == other.runtimeType && showToken == other.showToken;
}
enum RsHttpServerSessionEndReason {
finished,
cancelled,
}
class RsHttpServerTlsConfig {
final String cert;
final String privateKey;
const RsHttpServerTlsConfig({
required this.cert,
required this.privateKey,
});
@override
int get hashCode => cert.hashCode ^ privateKey.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RsHttpServerTlsConfig && runtimeType == other.runtimeType && cert == other.cert && privateKey == other.privateKey;
}
class RsHttpServerV2Config {
final String? pin;
const RsHttpServerV2Config({
this.pin,
});
@override
int get hashCode => pin.hashCode;
@override
bool operator ==(Object other) => identical(this, other) || other is RsHttpServerV2Config && runtimeType == other.runtimeType && pin == other.pin;
}
class RsHttpServerWebSendConfig {
final Map<String, FileDto> files;
final String? pin;
final RsHttpServerWebSendI18n i18N;
const RsHttpServerWebSendConfig({
required this.files,
this.pin,
required this.i18N,
});
@override
int get hashCode => files.hashCode ^ pin.hashCode ^ i18N.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RsHttpServerWebSendConfig && runtimeType == other.runtimeType && files == other.files && pin == other.pin && i18N == other.i18N;
}
class RsHttpServerWebSendI18n {
final String waiting;
final String enterPin;
final String invalidPin;
final String tooManyAttempts;
final String rejected;
final String files;
final String fileName;
final String size;
const RsHttpServerWebSendI18n({
required this.waiting,
required this.enterPin,
required this.invalidPin,
required this.tooManyAttempts,
required this.rejected,
required this.files,
required this.fileName,
required this.size,
});
@override
int get hashCode =>
waiting.hashCode ^
enterPin.hashCode ^
invalidPin.hashCode ^
tooManyAttempts.hashCode ^
rejected.hashCode ^
files.hashCode ^
fileName.hashCode ^
size.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RsHttpServerWebSendI18n &&
runtimeType == other.runtimeType &&
waiting == other.waiting &&
enterPin == other.enterPin &&
invalidPin == other.invalidPin &&
tooManyAttempts == other.tooManyAttempts &&
rejected == other.rejected &&
files == other.files &&
fileName == other.fileName &&
size == other.size;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+740
View File
@@ -0,0 +1,740 @@
use crate::api::stream::Dart2RustStreamReceiver;
use crate::frb_generated::StreamSink;
use bytes::Bytes;
use flutter_rust_bridge::frb;
use localsend::http::dto::{ProtocolType, RegisterDto};
use localsend::http::dto_v2::{ProtocolTypeV2, RegisterDtoV2};
use localsend::http::server::common::save::FileUploadTarget;
use localsend::http::server::internal::{InternalConfig, InternalEvent};
use localsend::http::server::v2::{PrepareUploadDecisionV2, ServerEventV2, SessionEndReasonV2};
use localsend::http::server::web::{WebSendConfig, WebSendEvent, WebSendI18n};
use localsend::http::server::{ServerConfigV2, TlsConfig};
use localsend::http::state::ClientInfo;
use localsend::model::transfer::{FileContent, FileDto};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Mutex as StdMutex;
use tokio::sync::{Mutex, mpsc, oneshot};
const EVENT_CHANNEL_CAPACITY: usize = 32;
const FILE_CHANNEL_CAPACITY: usize = 16;
pub struct RsHttpServerTlsConfig {
pub cert: String,
pub private_key: String,
}
pub struct RsHttpServerInfo {
pub alias: String,
pub version: String,
pub device_model: Option<String>,
pub device_type: Option<localsend::model::discovery::DeviceType>,
pub token: String,
}
pub struct RsHttpServerInternalConfig {
pub show_token: String,
}
pub struct RsHttpServerV2Config {
pub pin: Option<String>,
}
pub struct RsHttpServerWebSendI18n {
pub waiting: String,
pub enter_pin: String,
pub invalid_pin: String,
pub too_many_attempts: String,
pub rejected: String,
pub files: String,
pub file_name: String,
pub size: String,
}
pub struct RsHttpServerWebSendConfig {
pub files: HashMap<String, FileDto>,
pub pin: Option<String>,
pub i18n: RsHttpServerWebSendI18n,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RsHttpServerSessionEndReason {
Finished,
Cancelled,
}
#[frb(opaque)]
pub enum RsHttpServerEvent {
Show {
args: Vec<String>,
},
Register {
ip: String,
info: RegisterDto,
},
PrepareUpload {
ip: String,
info: RegisterDto,
files: HashMap<String, FileDto>,
request: Option<RsHttpServerPrepareUploadRequest>,
},
FileUpload {
session_id: String,
file_id: String,
file: FileDto,
request: Option<RsHttpServerFileUploadRequest>,
},
SessionEnd {
session_id: String,
reason: RsHttpServerSessionEndReason,
},
PrepareDownload {
ip: String,
session_id: String,
user_agent: Option<String>,
request: Option<RsHttpServerPrepareDownloadRequest>,
},
FileDownload {
session_id: String,
file_id: String,
file: FileDto,
request: Option<RsHttpServerFileDownloadRequest>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RsHttpServerEventKind {
Show,
Register,
PrepareUpload,
FileUpload,
SessionEnd,
PrepareDownload,
FileDownload,
}
impl RsHttpServerEvent {
#[frb(sync)]
pub fn kind(&self) -> RsHttpServerEventKind {
match self {
Self::Show { .. } => RsHttpServerEventKind::Show,
Self::Register { .. } => RsHttpServerEventKind::Register,
Self::PrepareUpload { .. } => RsHttpServerEventKind::PrepareUpload,
Self::FileUpload { .. } => RsHttpServerEventKind::FileUpload,
Self::SessionEnd { .. } => RsHttpServerEventKind::SessionEnd,
Self::PrepareDownload { .. } => RsHttpServerEventKind::PrepareDownload,
Self::FileDownload { .. } => RsHttpServerEventKind::FileDownload,
}
}
#[frb(sync)]
pub fn args(&self) -> Option<Vec<String>> {
match self {
Self::Show { args } => Some(args.clone()),
_ => None,
}
}
#[frb(sync)]
pub fn ip(&self) -> Option<String> {
match self {
Self::Register { ip, .. }
| Self::PrepareUpload { ip, .. }
| Self::PrepareDownload { ip, .. } => Some(ip.clone()),
_ => None,
}
}
#[frb(sync)]
pub fn info(&self) -> Option<RegisterDto> {
match self {
Self::Register { info, .. } | Self::PrepareUpload { info, .. } => Some(info.clone()),
_ => None,
}
}
#[frb(sync)]
pub fn files(&self) -> Option<HashMap<String, FileDto>> {
match self {
Self::PrepareUpload { files, .. } => Some(files.clone()),
_ => None,
}
}
#[frb(sync)]
pub fn session_id(&self) -> Option<String> {
match self {
Self::FileUpload { session_id, .. }
| Self::SessionEnd { session_id, .. }
| Self::PrepareDownload { session_id, .. }
| Self::FileDownload { session_id, .. } => Some(session_id.clone()),
_ => None,
}
}
#[frb(sync)]
pub fn file_id(&self) -> Option<String> {
match self {
Self::FileUpload { file_id, .. } | Self::FileDownload { file_id, .. } => {
Some(file_id.clone())
}
_ => None,
}
}
#[frb(sync)]
pub fn file(&self) -> Option<FileDto> {
match self {
Self::FileUpload { file, .. } | Self::FileDownload { file, .. } => Some(file.clone()),
_ => None,
}
}
#[frb(sync)]
pub fn reason(&self) -> Option<RsHttpServerSessionEndReason> {
match self {
Self::SessionEnd { reason, .. } => Some(*reason),
_ => None,
}
}
#[frb(sync)]
pub fn user_agent(&self) -> Option<String> {
match self {
Self::PrepareDownload { user_agent, .. } => user_agent.clone(),
_ => None,
}
}
#[frb(sync)]
pub fn take_prepare_upload_request(&mut self) -> Option<RsHttpServerPrepareUploadRequest> {
match self {
Self::PrepareUpload { request, .. } => request.take(),
_ => None,
}
}
#[frb(sync)]
pub fn take_file_upload_request(&mut self) -> Option<RsHttpServerFileUploadRequest> {
match self {
Self::FileUpload { request, .. } => request.take(),
_ => None,
}
}
#[frb(sync)]
pub fn take_prepare_download_request(&mut self) -> Option<RsHttpServerPrepareDownloadRequest> {
match self {
Self::PrepareDownload { request, .. } => request.take(),
_ => None,
}
}
#[frb(sync)]
pub fn take_file_download_request(&mut self) -> Option<RsHttpServerFileDownloadRequest> {
match self {
Self::FileDownload { request, .. } => request.take(),
_ => None,
}
}
}
#[frb(opaque)]
pub struct RsHttpServer {
event_tx: StdMutex<Option<mpsc::Sender<RsHttpServerEvent>>>,
event_rx: Mutex<Option<mpsc::Receiver<RsHttpServerEvent>>>,
stop_tx: StdMutex<Option<oneshot::Sender<()>>>,
}
#[frb(sync)]
pub fn create_http_server() -> RsHttpServer {
let (event_tx, event_rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY);
RsHttpServer {
event_tx: StdMutex::new(Some(event_tx)),
event_rx: Mutex::new(Some(event_rx)),
stop_tx: StdMutex::new(None),
}
}
impl RsHttpServer {
pub async fn start(
&self,
port: u16,
tls: Option<RsHttpServerTlsConfig>,
info: RsHttpServerInfo,
internal: Option<RsHttpServerInternalConfig>,
v2: Option<RsHttpServerV2Config>,
web_send: Option<RsHttpServerWebSendConfig>,
) -> anyhow::Result<()> {
if self.stop_tx.lock().unwrap().is_some() {
return Err(anyhow::anyhow!("HTTP server already started"));
}
let event_tx = self
.event_tx
.lock()
.unwrap()
.as_ref()
.cloned()
.ok_or_else(|| anyhow::anyhow!("HTTP server already stopped"))?;
let internal_config = internal.map(|config| {
let (tx, rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY);
spawn_internal_event_forwarder(rx, event_tx.clone());
InternalConfig {
show_token: config.show_token,
event_tx: tx,
}
});
let v2_config = v2.map(|config| {
let (tx, rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY);
spawn_v2_event_forwarder(rx, event_tx.clone());
ServerConfigV2 {
pin: config.pin,
event_tx: tx,
}
});
let web_send_config = web_send.map(|config| {
let (tx, rx) = mpsc::channel(EVENT_CHANNEL_CAPACITY);
spawn_web_send_event_forwarder(rx, event_tx);
WebSendConfig {
files: config.files,
pin: config.pin,
i18n: WebSendI18n {
waiting: config.i18n.waiting,
enter_pin: config.i18n.enter_pin,
invalid_pin: config.i18n.invalid_pin,
too_many_attempts: config.i18n.too_many_attempts,
rejected: config.i18n.rejected,
files: config.i18n.files,
file_name: config.i18n.file_name,
size: config.i18n.size,
},
event_tx: tx,
}
});
let (stop_tx, stop_rx) = oneshot::channel();
localsend::http::server::start_with_port(
port,
tls.map(|tls| TlsConfig {
cert: tls.cert,
private_key: tls.private_key,
}),
ClientInfo {
alias: info.alias,
version: info.version,
device_model: info.device_model,
device_type: info.device_type,
token: info.token,
},
internal_config,
v2_config,
web_send_config,
stop_rx,
)
.await?;
*self.stop_tx.lock().unwrap() = Some(stop_tx);
Ok(())
}
pub async fn listen(&self, sink: StreamSink<RsHttpServerEvent>) {
let Some(mut event_rx) = self.event_rx.lock().await.take() else {
let _ = sink.add_error(anyhow::anyhow!("HTTP server events already listened to"));
return;
};
while let Some(event) = event_rx.recv().await {
if sink.add(event).is_err() {
break;
}
}
}
#[frb(sync)]
pub fn stop(&self) {
if let Some(stop_tx) = self.stop_tx.lock().unwrap().take() {
let _ = stop_tx.send(());
}
self.event_tx.lock().unwrap().take();
}
}
impl Drop for RsHttpServer {
fn drop(&mut self) {
if let Ok(stop_tx) = self.stop_tx.get_mut() {
if let Some(stop_tx) = stop_tx.take() {
let _ = stop_tx.send(());
}
}
}
}
#[frb(opaque)]
pub struct RsHttpServerPrepareUploadRequest {
decision_tx: Mutex<Option<oneshot::Sender<PrepareUploadDecisionV2>>>,
}
impl RsHttpServerPrepareUploadRequest {
pub async fn accept(&self, file_ids: HashSet<String>) -> anyhow::Result<()> {
self.respond(PrepareUploadDecisionV2::Accept(file_ids))
.await
}
pub async fn decline(&self) -> anyhow::Result<()> {
self.respond(PrepareUploadDecisionV2::Decline).await
}
async fn respond(&self, decision: PrepareUploadDecisionV2) -> anyhow::Result<()> {
let decision_tx = self
.decision_tx
.lock()
.await
.take()
.ok_or_else(|| anyhow::anyhow!("Prepare-upload request already answered"))?;
decision_tx
.send(decision)
.map_err(|_| anyhow::anyhow!("Prepare-upload request closed"))
}
}
#[frb(opaque)]
pub struct RsHttpServerFileUploadRequest {
file_size: u64,
target_tx: Mutex<Option<oneshot::Sender<FileUploadTarget>>>,
}
impl RsHttpServerFileUploadRequest {
pub async fn save_to_path(&self, path: String) -> anyhow::Result<()> {
let target_tx = self.take_target().await?;
let (result_tx, result_rx) = oneshot::channel();
target_tx
.send(FileUploadTarget::Path {
path: PathBuf::from(path),
result_tx,
})
.map_err(|_| anyhow::anyhow!("File-upload request closed"))?;
flatten_file_result(result_rx).await
}
pub async fn save_to_file_descriptor(&self, fd: i32) -> anyhow::Result<()> {
#[cfg(target_os = "android")]
{
let target_tx = self.take_target().await?;
let (result_tx, result_rx) = oneshot::channel();
target_tx
.send(FileUploadTarget::Fd { fd, result_tx })
.map_err(|_| anyhow::anyhow!("File-upload request closed"))?;
return flatten_file_result(result_rx).await;
}
#[cfg(not(target_os = "android"))]
{
let _ = fd;
Err(anyhow::anyhow!(
"File descriptors are only supported on Android"
))
}
}
pub async fn receive(&self, sink: StreamSink<Vec<u8>>) -> anyhow::Result<()> {
let target_tx = self.take_target().await?;
let (binary_tx, mut binary_rx) = mpsc::channel::<Bytes>(FILE_CHANNEL_CAPACITY);
let (result_tx, result_rx) = oneshot::channel::<Result<(), String>>();
target_tx
.send(FileUploadTarget::Stream {
binary_tx,
result_rx,
})
.map_err(|_| anyhow::anyhow!("File-upload request closed"))?;
let mut received = 0_u64;
let result = 'receive: {
while let Some(chunk) = binary_rx.recv().await {
received += chunk.len() as u64;
if received > self.file_size {
break 'receive Err(format!(
"Expected {} bytes, received at least {received}",
self.file_size
));
}
if sink.add(chunk.to_vec()).is_err() {
break 'receive Err("File-upload stream listener closed".to_string());
}
}
Ok(())
};
let result = result.and_then(|()| {
if received == self.file_size {
Ok(())
} else {
Err(format!(
"Expected {} bytes, received {received}",
self.file_size
))
}
});
let _ = result_tx.send(result.clone());
result.map_err(anyhow::Error::msg)
}
async fn take_target(&self) -> anyhow::Result<oneshot::Sender<FileUploadTarget>> {
self.target_tx
.lock()
.await
.take()
.ok_or_else(|| anyhow::anyhow!("File-upload request already answered"))
}
}
#[frb(opaque)]
pub struct RsHttpServerPrepareDownloadRequest {
decision_tx: Mutex<Option<oneshot::Sender<bool>>>,
}
impl RsHttpServerPrepareDownloadRequest {
pub async fn accept(&self) -> anyhow::Result<()> {
self.respond(true).await
}
pub async fn decline(&self) -> anyhow::Result<()> {
self.respond(false).await
}
async fn respond(&self, accepted: bool) -> anyhow::Result<()> {
let decision_tx = self
.decision_tx
.lock()
.await
.take()
.ok_or_else(|| anyhow::anyhow!("Prepare-download request already answered"))?;
decision_tx
.send(accepted)
.map_err(|_| anyhow::anyhow!("Prepare-download request closed"))
}
}
#[frb(opaque)]
pub struct RsHttpServerFileDownloadRequest {
content_tx: Mutex<Option<oneshot::Sender<FileContent>>>,
}
impl RsHttpServerFileDownloadRequest {
pub async fn provide_path(&self, path: String) -> anyhow::Result<()> {
self.send_content(FileContent::Path(PathBuf::from(path)))
.await
}
pub async fn provide_file_descriptor(&self, fd: i32) -> anyhow::Result<()> {
#[cfg(target_os = "android")]
{
return self.send_content(FileContent::Fd(fd)).await;
}
#[cfg(not(target_os = "android"))]
{
let _ = fd;
Err(anyhow::anyhow!(
"File descriptors are only supported on Android"
))
}
}
pub async fn provide_bytes(&self, data: Vec<u8>) -> anyhow::Result<()> {
let (tx, rx) = mpsc::channel(1);
tx.send(Bytes::from(data))
.await
.map_err(|_| anyhow::anyhow!("Failed to buffer download content"))?;
drop(tx);
self.send_content(FileContent::Stream(rx)).await
}
pub async fn provide_stream(&self, stream: Dart2RustStreamReceiver) -> anyhow::Result<()> {
self.send_content(FileContent::Stream(stream.receiver))
.await
}
async fn send_content(&self, content: FileContent) -> anyhow::Result<()> {
let content_tx = self
.content_tx
.lock()
.await
.take()
.ok_or_else(|| anyhow::anyhow!("File-download request already answered"))?;
content_tx
.send(content)
.map_err(|_| anyhow::anyhow!("File-download request closed"))
}
}
async fn flatten_file_result(
result_rx: oneshot::Receiver<Result<(), String>>,
) -> anyhow::Result<()> {
result_rx
.await
.map_err(|_| anyhow::anyhow!("File-upload result channel closed"))?
.map_err(anyhow::Error::msg)
}
fn spawn_internal_event_forwarder(
mut rx: mpsc::Receiver<InternalEvent>,
event_tx: mpsc::Sender<RsHttpServerEvent>,
) {
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let event = match event {
InternalEvent::Show { args } => RsHttpServerEvent::Show { args },
};
if event_tx.send(event).await.is_err() {
break;
}
}
});
}
fn spawn_v2_event_forwarder(
mut rx: mpsc::Receiver<ServerEventV2>,
event_tx: mpsc::Sender<RsHttpServerEvent>,
) {
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let event = match event {
ServerEventV2::Register { ip, info } => RsHttpServerEvent::Register {
ip: ip.to_string(),
info: register_dto_from_v2(info),
},
ServerEventV2::PrepareUpload {
ip,
info,
files,
decision_tx,
} => RsHttpServerEvent::PrepareUpload {
ip: ip.to_string(),
info: register_dto_from_v2(info),
files,
request: Some(RsHttpServerPrepareUploadRequest {
decision_tx: Mutex::new(Some(decision_tx)),
}),
},
ServerEventV2::FileUpload {
session_id,
file_id,
file,
target_tx,
} => {
let file_size = file.size;
RsHttpServerEvent::FileUpload {
session_id,
file_id,
file,
request: Some(RsHttpServerFileUploadRequest {
file_size,
target_tx: Mutex::new(Some(target_tx)),
}),
}
}
ServerEventV2::SessionEnd { session_id, reason } => RsHttpServerEvent::SessionEnd {
session_id,
reason: match reason {
SessionEndReasonV2::Finished => RsHttpServerSessionEndReason::Finished,
SessionEndReasonV2::Cancelled => RsHttpServerSessionEndReason::Cancelled,
},
},
};
if event_tx.send(event).await.is_err() {
break;
}
}
});
}
fn spawn_web_send_event_forwarder(
mut rx: mpsc::Receiver<WebSendEvent>,
event_tx: mpsc::Sender<RsHttpServerEvent>,
) {
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let event = match event {
WebSendEvent::PrepareDownload {
ip,
session_id,
user_agent,
decision_tx,
} => RsHttpServerEvent::PrepareDownload {
ip: ip.to_string(),
session_id,
user_agent,
request: Some(RsHttpServerPrepareDownloadRequest {
decision_tx: Mutex::new(Some(decision_tx)),
}),
},
WebSendEvent::FileDownload {
session_id,
file_id,
file,
content_tx,
} => RsHttpServerEvent::FileDownload {
session_id,
file_id,
file,
request: Some(RsHttpServerFileDownloadRequest {
content_tx: Mutex::new(Some(content_tx)),
}),
},
};
if event_tx.send(event).await.is_err() {
break;
}
}
});
}
fn register_dto_from_v2(info: RegisterDtoV2) -> RegisterDto {
RegisterDto {
alias: info.alias,
version: info.version,
device_model: info.device_model,
device_type: info.device_type,
token: info.fingerprint,
port: info.port,
protocol: match info.protocol {
ProtocolTypeV2::Http => ProtocolType::Http,
ProtocolTypeV2::Https => ProtocolType::Https,
},
has_web_interface: info.download,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn starts_and_stops_server() {
let listener = std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let server = create_http_server();
server
.start(
port,
None,
RsHttpServerInfo {
alias: "test".to_string(),
version: "2.1".to_string(),
device_model: None,
device_type: None,
token: "test-token".to_string(),
},
None,
Some(RsHttpServerV2Config { pin: None }),
None,
)
.await
.unwrap();
server.stop();
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod crypto;
pub mod http;
pub mod http_server;
pub mod logging;
pub mod model;
pub mod stream;
File diff suppressed because it is too large Load Diff