Files

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;
}
}
}