541 lines
16 KiB
Dart
541 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:isolate';
|
|
import 'dart:typed_data';
|
|
|
|
import 'agent_client.dart';
|
|
import 'models.dart';
|
|
import 'windows_webrtc_peer.dart';
|
|
|
|
const _frameHeaderBytes = 40;
|
|
const _maxDimension = 16384;
|
|
const _maxPixels = 33177600;
|
|
const _maxCompressedBytes = 64 * 1024 * 1024;
|
|
const _maxControlLineBytes = 384 * 1024;
|
|
|
|
class WindowsAgentClient implements RemoteDesktopClient {
|
|
WindowsAgentClient({
|
|
required this.host,
|
|
required this.settings,
|
|
WindowsWebRtcPeerFactory? webRtcPeerFactory,
|
|
}) : _webRtcPeerFactory =
|
|
webRtcPeerFactory ??
|
|
(Platform.isAndroid ? FlutterWindowsWebRtcPeer.new : null);
|
|
|
|
final RemoteHost host;
|
|
final AppSettings settings;
|
|
final WindowsWebRtcPeerFactory? _webRtcPeerFactory;
|
|
final StreamController<AgentState> _states =
|
|
StreamController<AgentState>.broadcast(sync: true);
|
|
final StreamController<RemoteFrame> _frames =
|
|
StreamController<RemoteFrame>.broadcast(sync: true);
|
|
|
|
Socket? _socket;
|
|
WindowsWebRtcPeer? _webRtcPeer;
|
|
bool _closed = false;
|
|
bool _desktopOpen = false;
|
|
int _sequence = 0;
|
|
int _frameWidth = 0;
|
|
int _frameHeight = 0;
|
|
int _pointerX = 0;
|
|
int _pointerY = 0;
|
|
|
|
@override
|
|
Stream<AgentState> get states => _states.stream;
|
|
|
|
@override
|
|
Stream<RemoteFrame> get frames => _frames.stream;
|
|
|
|
@override
|
|
Future<void> connect({String? pairingCode, required String totpCode}) async {
|
|
if (pairingCode != null) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
await close();
|
|
_closed = false;
|
|
_emit(AgentPhase.connecting);
|
|
try {
|
|
final endpoint = normalizeWindowsAgentUri(host.address);
|
|
final socket = await Socket.connect(
|
|
endpoint.host,
|
|
endpoint.port,
|
|
timeout: const Duration(seconds: 12),
|
|
);
|
|
socket.setOption(SocketOption.tcpNoDelay, true);
|
|
_socket = socket;
|
|
final reader = _SocketReader(socket);
|
|
|
|
final hello = await _readJson(reader, const Duration(seconds: 8));
|
|
if (hello['kind'] != 'windows_agent_hello' ||
|
|
hello['protocol_major'] != 1 ||
|
|
(hello['protocol_minor'] is! int || hello['protocol_minor'] < 1) ||
|
|
hello['totp_required'] != true) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
|
|
_emit(AgentPhase.authenticating);
|
|
_send({'kind': 'authenticate_totp', 'totp_code': totpCode});
|
|
final authenticated = await _readJson(reader, const Duration(seconds: 8));
|
|
if (authenticated['kind'] != 'authentication_ok') {
|
|
throw const AgentException(AgentError.totp);
|
|
}
|
|
|
|
_emit(AgentPhase.openingDesktop);
|
|
final opened = await _openDesktop(reader, hello);
|
|
if (opened['kind'] != 'desktop_opened' ||
|
|
(opened['encoding'] != 'zlib_bgra' && opened['encoding'] != 'h264')) {
|
|
throw const AgentException(AgentError.desktop);
|
|
}
|
|
_desktopOpen = true;
|
|
_emit(AgentPhase.waitingForFrame);
|
|
if (opened['transport'] == 'webrtc') {
|
|
final width = opened['width'];
|
|
final height = opened['height'];
|
|
final peer = _webRtcPeer;
|
|
if (width is! int ||
|
|
height is! int ||
|
|
width <= 0 ||
|
|
height <= 0 ||
|
|
width > _maxDimension ||
|
|
height > _maxDimension ||
|
|
width * height > _maxPixels ||
|
|
peer == null) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
await peer.waitUntilReady(const Duration(seconds: 5));
|
|
await peer.waitForFirstFrame(const Duration(seconds: 15));
|
|
_frameWidth = peer.videoWidth > 0 ? peer.videoWidth : width;
|
|
_frameHeight = peer.videoHeight > 0 ? peer.videoHeight : height;
|
|
_sequence = 1;
|
|
_emit(AgentPhase.connected);
|
|
_frames.add(
|
|
RemoteFrame(
|
|
sequence: _sequence,
|
|
width: _frameWidth,
|
|
height: _frameHeight,
|
|
bgra: Uint8List(0),
|
|
textureId: peer.textureId,
|
|
),
|
|
);
|
|
unawaited(_monitorControl(reader));
|
|
} else {
|
|
unawaited(_readFrames(reader));
|
|
}
|
|
} on AgentException catch (error) {
|
|
await _fail(error.error);
|
|
rethrow;
|
|
} on Object {
|
|
await _fail(AgentError.network);
|
|
throw const AgentException(AgentError.network);
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _openDesktop(
|
|
_SocketReader reader,
|
|
Map<String, dynamic> hello,
|
|
) async {
|
|
final peerFactory = _webRtcPeerFactory;
|
|
final transports = hello['supported_media_transports'];
|
|
final webRtcAdvertised =
|
|
transports is List && transports.whereType<String>().contains('webrtc');
|
|
if (peerFactory != null && webRtcAdvertised) {
|
|
_send({
|
|
'kind': 'open_desktop',
|
|
'capture_mode': 'compatibility',
|
|
'media_transport': 'webrtc',
|
|
'frames_per_second': settings.framesPerSecond.clamp(1, 30),
|
|
});
|
|
final ready = await _readJson(reader, const Duration(seconds: 15));
|
|
if (ready['kind'] == 'webrtc_media_ready') {
|
|
final width = ready['width'];
|
|
final height = ready['height'];
|
|
if (width is! int ||
|
|
height is! int ||
|
|
width <= 0 ||
|
|
height <= 0 ||
|
|
width > _maxDimension ||
|
|
height > _maxDimension ||
|
|
width * height > _maxPixels ||
|
|
ready['encrypted'] != true) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
final peer = peerFactory();
|
|
_webRtcPeer = peer;
|
|
await peer.start(
|
|
iceServers: _parseIceServers(ready['ice_servers']),
|
|
onSignal: (kind, payload) => _send({
|
|
'kind': 'webrtc_signal',
|
|
'signal_kind': kind,
|
|
'payload': payload,
|
|
}),
|
|
onFailure: () {
|
|
if (_desktopOpen) unawaited(_fail(AgentError.network));
|
|
},
|
|
);
|
|
while (true) {
|
|
final response = await _readJson(reader, const Duration(seconds: 20));
|
|
if (response['kind'] == 'webrtc_signal') {
|
|
final kind = response['signal_kind'];
|
|
final payload = response['payload'];
|
|
if (kind is! String || payload is! String) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
await peer.applySignal(kind, payload);
|
|
continue;
|
|
}
|
|
if (response['kind'] == 'desktop_opened' &&
|
|
response['transport'] == 'webrtc') {
|
|
return response;
|
|
}
|
|
if (response['kind'] != 'webrtc_media_unavailable') {
|
|
throw const AgentException(AgentError.desktop);
|
|
}
|
|
await peer.close();
|
|
_webRtcPeer = null;
|
|
break;
|
|
}
|
|
} else if (ready['kind'] != 'webrtc_media_unavailable') {
|
|
throw const AgentException(AgentError.desktop);
|
|
}
|
|
}
|
|
|
|
_send({
|
|
'kind': 'open_desktop',
|
|
'capture_mode': 'compatibility',
|
|
'media_transport': 'plain_stream',
|
|
'frames_per_second': settings.framesPerSecond.clamp(1, 30),
|
|
});
|
|
return _readJson(reader, const Duration(seconds: 15));
|
|
}
|
|
|
|
Future<void> _monitorControl(_SocketReader reader) async {
|
|
try {
|
|
while (!_closed) {
|
|
final response = jsonDecode(await reader.readLine());
|
|
if (response is! Map<String, dynamic>) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
if (response['kind'] == 'desktop_error') {
|
|
throw const AgentException(AgentError.desktop);
|
|
}
|
|
}
|
|
} on AgentException catch (error) {
|
|
await _fail(error.error);
|
|
} on Object {
|
|
if (!_closed) await _fail(AgentError.network);
|
|
}
|
|
}
|
|
|
|
Future<void> _readFrames(_SocketReader reader) async {
|
|
try {
|
|
while (!_closed) {
|
|
final magic = await reader
|
|
.readExactly(4)
|
|
.timeout(const Duration(seconds: 45));
|
|
final marker = ascii.decode(magic, allowInvalid: true);
|
|
if (marker == 'RDWE') {
|
|
final lengthBytes = await reader.readExactly(4);
|
|
final length = ByteData.sublistView(
|
|
lengthBytes,
|
|
).getUint32(0, Endian.little);
|
|
if (length > 16 * 1024) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
await reader.readExactly(length);
|
|
throw const AgentException(AgentError.desktop);
|
|
}
|
|
if (marker != 'RDWF') {
|
|
throw const AgentException(AgentError.frame);
|
|
}
|
|
final tail = await reader.readExactly(_frameHeaderBytes - 4);
|
|
final header = ByteData.sublistView(tail);
|
|
final version = header.getUint8(0);
|
|
final codec = header.getUint8(1);
|
|
final width = header.getUint32(4, Endian.little);
|
|
final height = header.getUint32(8, Endian.little);
|
|
final uncompressedBytes = header.getUint32(12, Endian.little);
|
|
final compressedBytes = header.getUint32(16, Endian.little);
|
|
final pixels = width * height;
|
|
if (version != 2 ||
|
|
codec != 1 ||
|
|
width <= 0 ||
|
|
height <= 0 ||
|
|
width > _maxDimension ||
|
|
height > _maxDimension ||
|
|
pixels > _maxPixels ||
|
|
uncompressedBytes != pixels * 4 ||
|
|
compressedBytes <= 0 ||
|
|
compressedBytes > _maxCompressedBytes) {
|
|
throw const AgentException(AgentError.frame);
|
|
}
|
|
final compressed = await reader.readExactly(compressedBytes);
|
|
final bgra = await Isolate.run(
|
|
() => Uint8List.fromList(ZLibCodec().decode(compressed)),
|
|
);
|
|
if (bgra.length != uncompressedBytes) {
|
|
throw const AgentException(AgentError.frame);
|
|
}
|
|
if (_closed) return;
|
|
_frameWidth = width;
|
|
_frameHeight = height;
|
|
_sequence += 1;
|
|
_emit(AgentPhase.connected);
|
|
_frames.add(
|
|
RemoteFrame(
|
|
sequence: _sequence,
|
|
width: width,
|
|
height: height,
|
|
bgra: bgra,
|
|
),
|
|
);
|
|
}
|
|
} on AgentException catch (error) {
|
|
await _fail(error.error);
|
|
} on Object {
|
|
if (!_closed) await _fail(AgentError.network);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void sendPointer(int x, int y) {
|
|
if (!_desktopOpen || _frameWidth <= 0 || _frameHeight <= 0) return;
|
|
_pointerX = (x.clamp(0, _frameWidth - 1) * 65535 ~/ _frameWidth).clamp(
|
|
0,
|
|
65535,
|
|
);
|
|
_pointerY = (y.clamp(0, _frameHeight - 1) * 65535 ~/ _frameHeight).clamp(
|
|
0,
|
|
65535,
|
|
);
|
|
_sendMouse();
|
|
}
|
|
|
|
@override
|
|
void sendButton(String button, bool pressed) {
|
|
if (!_desktopOpen) return;
|
|
final action = switch (button) {
|
|
'left' => pressed ? 'left_down' : 'left_up',
|
|
'right' => pressed ? 'right_down' : 'right_up',
|
|
'middle' => pressed ? 'middle_down' : 'middle_up',
|
|
_ => null,
|
|
};
|
|
if (action != null) _sendMouse(action);
|
|
}
|
|
|
|
void _sendMouse([String? action]) {
|
|
final command = <String, Object?>{
|
|
'kind': 'input',
|
|
'input_type': 'mouse',
|
|
'x': _pointerX,
|
|
'y': _pointerY,
|
|
};
|
|
command['mouse_action'] = action;
|
|
_send(command);
|
|
}
|
|
|
|
@override
|
|
void sendKey(int keysym) {
|
|
if (!_desktopOpen) return;
|
|
final virtualKey = switch (keysym) {
|
|
0xff08 => 0x08,
|
|
0xff09 => 0x09,
|
|
0xff0d => 0x0d,
|
|
0xff1b => 0x1b,
|
|
>= 0x20 && <= 0x7e => keysym,
|
|
_ => null,
|
|
};
|
|
if (virtualKey == null) return;
|
|
for (final down in const [true, false]) {
|
|
_send({
|
|
'kind': 'input',
|
|
'input_type': 'key',
|
|
'code': virtualKey,
|
|
'down': down,
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void sendText(String value) {
|
|
if (!_desktopOpen || value.isEmpty) return;
|
|
final runes = value.runes.toList(growable: false);
|
|
for (var start = 0; start < runes.length; start += 256) {
|
|
final end = (start + 256).clamp(0, runes.length);
|
|
_send({
|
|
'kind': 'input',
|
|
'input_type': 'text',
|
|
'text': String.fromCharCodes(runes.sublist(start, end)),
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void acknowledgeFrame(int sequence) {}
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
if (_closed) return;
|
|
try {
|
|
_send({'kind': 'close'});
|
|
await _socket?.flush();
|
|
} on Object {
|
|
// The remote side may already have closed the stream.
|
|
}
|
|
_closed = true;
|
|
_desktopOpen = false;
|
|
await _webRtcPeer?.close();
|
|
_webRtcPeer = null;
|
|
_socket?.destroy();
|
|
_socket = null;
|
|
_emit(AgentPhase.disconnected);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
await close();
|
|
await _states.close();
|
|
await _frames.close();
|
|
}
|
|
|
|
void _send(Map<String, Object?> value) {
|
|
final peer = _webRtcPeer;
|
|
if (_desktopOpen && peer != null) {
|
|
final pointer =
|
|
value['kind'] == 'input' &&
|
|
value['input_type'] == 'mouse' &&
|
|
value['mouse_action'] == null;
|
|
try {
|
|
peer.sendControl(value, pointer: pointer);
|
|
} on Object {
|
|
unawaited(_fail(AgentError.network));
|
|
}
|
|
return;
|
|
}
|
|
final socket = _socket;
|
|
if (socket == null) return;
|
|
socket.add(utf8.encode('${jsonEncode(value)}\n'));
|
|
}
|
|
|
|
void _emit(AgentPhase phase, [AgentError? error]) {
|
|
if (!_states.isClosed) _states.add(AgentState(phase, error));
|
|
}
|
|
|
|
Future<void> _fail(AgentError error) async {
|
|
if (_closed) return;
|
|
_closed = true;
|
|
_desktopOpen = false;
|
|
_emit(AgentPhase.failed, error);
|
|
await _webRtcPeer?.close();
|
|
_webRtcPeer = null;
|
|
_socket?.destroy();
|
|
_socket = null;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _readJson(
|
|
_SocketReader reader,
|
|
Duration timeout,
|
|
) async {
|
|
final line = await reader.readLine().timeout(timeout);
|
|
final decoded = jsonDecode(line);
|
|
if (decoded is! Map<String, dynamic>) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
List<Map<String, dynamic>> _parseIceServers(Object? value) {
|
|
if (value == null) return const [];
|
|
if (value is! List || value.length > 8) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
final servers = <Map<String, dynamic>>[];
|
|
for (final item in value) {
|
|
if (item is! Map<String, dynamic>) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
final rawUrls = item['urls'];
|
|
if (rawUrls is List && rawUrls.any((url) => url is! String)) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
final urls = rawUrls is String
|
|
? [rawUrls]
|
|
: rawUrls is List
|
|
? rawUrls.whereType<String>().toList(growable: false)
|
|
: const <String>[];
|
|
final username = item['username'] as String? ?? '';
|
|
final credential = item['credential'] as String? ?? '';
|
|
if (urls.isEmpty ||
|
|
urls.length > 8 ||
|
|
username.length > 1024 ||
|
|
credential.length > 1024 ||
|
|
username.isEmpty != credential.isEmpty ||
|
|
urls.any(
|
|
(url) =>
|
|
url.isEmpty ||
|
|
url.length > 2048 ||
|
|
!RegExp(r'^(stun|stuns|turn|turns):').hasMatch(url),
|
|
)) {
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
servers.add({
|
|
'urls': urls,
|
|
if (username.isNotEmpty) 'username': username,
|
|
if (credential.isNotEmpty) 'credential': credential,
|
|
});
|
|
}
|
|
return servers;
|
|
}
|
|
|
|
class _SocketReader {
|
|
_SocketReader(Stream<Uint8List> stream) : _iterator = StreamIterator(stream);
|
|
|
|
final StreamIterator<Uint8List> _iterator;
|
|
Uint8List _chunk = Uint8List(0);
|
|
int _offset = 0;
|
|
|
|
Future<Uint8List> readExactly(int length) async {
|
|
if (length < 0) throw const AgentException(AgentError.protocol);
|
|
final output = Uint8List(length);
|
|
var written = 0;
|
|
while (written < length) {
|
|
await _ensureData();
|
|
final available = _chunk.length - _offset;
|
|
final take = (length - written < available)
|
|
? length - written
|
|
: available;
|
|
output.setRange(written, written + take, _chunk, _offset);
|
|
written += take;
|
|
_offset += take;
|
|
}
|
|
return output;
|
|
}
|
|
|
|
Future<String> readLine() async {
|
|
final output = BytesBuilder(copy: false);
|
|
while (output.length <= _maxControlLineBytes) {
|
|
await _ensureData();
|
|
final newline = _chunk.indexOf(10, _offset);
|
|
if (newline >= 0) {
|
|
output.add(Uint8List.sublistView(_chunk, _offset, newline));
|
|
_offset = newline + 1;
|
|
return utf8.decode(output.takeBytes());
|
|
}
|
|
output.add(Uint8List.sublistView(_chunk, _offset));
|
|
_offset = _chunk.length;
|
|
}
|
|
throw const AgentException(AgentError.protocol);
|
|
}
|
|
|
|
Future<void> _ensureData() async {
|
|
while (_offset >= _chunk.length) {
|
|
if (!await _iterator.moveNext()) {
|
|
throw const AgentException(AgentError.network);
|
|
}
|
|
_chunk = _iterator.current;
|
|
_offset = 0;
|
|
}
|
|
}
|
|
}
|