Files
Qlyra/lib/core/protocol/packet.dart
T

239 lines
7.7 KiB
Dart

import 'dart:typed_data';
import 'dart:isolate';
import 'package:dart_lz4/dart_lz4.dart';
import 'package:libcompress/libcompress.dart';
import 'package:msgpack_dart/msgpack_dart.dart' as msgpack;
import 'lz4_block.dart';
/// ver(1) + cmd(1) + seq(2) + opcode(2) + packedLen(4) = 10
const int headerSize = 10;
const int _maxDecompressedSize = 1048576; // 1 MB
/// Типы команд в протоколе
abstract class CmdType {
static const int request =
0; // запрос клиента / пуш от сервера (направление определяет смысл)
static const int push = 0; // пуш от сервера (имеет смысл только для incoming)
static const int ok = 1; // ответ: ок
static const int notFound = 2; // ответ: не найдено
static const int error = 3; // ответ: ошибка
}
/// Распакованный бинарный пакет
///
/// Формат заголовка (10 байт):
/// ```
/// [0] ver — версия протокола (uint8) (по умолчанию 10)
/// [1] cmd — тип команды (uint8) (при отправке от клиента равно 0)
/// [2..3] seq — порядковый номер (uint16 BE)
/// [4..5] opcode — код операции (uint16 BE)
/// [6..9] packedLen — флаг сжатия [6] + длина payload [7..9] (uint32 BE)
/// [10..] payload — данные в MsgPack, опционально сжатые LZ4
/// ```
class Packet {
int api;
int cmd;
int seq;
int opcode;
dynamic payload;
Packet({
this.api = 10,
this.cmd = 0,
this.seq = 0,
this.opcode = 0,
this.payload,
});
bool get isOk => cmd == CmdType.ok;
bool get isError => cmd == CmdType.error;
bool get isPush => cmd == CmdType.push;
@override
String toString() =>
'Packet(ver=$api cmd=$cmd seq=$seq opcode=$opcode payload=$payload)';
}
class PacketError implements Exception {
final String message;
final String? errorKey;
const PacketError(this.message, {this.errorKey});
@override
String toString() => message;
}
class SessionExpiredException extends PacketError {
const SessionExpiredException(super.message);
}
String messageFromErrorPayload(dynamic payload) {
if (payload is Map) {
final msg = payload['message'];
if (msg == 'FAIL_WRONG_PASSWORD' || msg == 'FAIL_LOGIN_TOKEN') {
return 'Ваш токен был отклонён сервером, хм... Попробуйте войти ещё раз.';
}
for (final key in ['localizedMessage', 'message', 'title']) {
final v = payload[key];
if (v is String && v.trim().isNotEmpty) return v.trim();
}
return 'Неизвестная ошибка';
}
if (payload == null) return 'Неизвестная ошибка';
final s = payload.toString();
return s.isNotEmpty ? s : 'Неизвестная ошибка';
}
bool isSessionExpiredPayload(dynamic payload) {
return payload is Map &&
(payload['message'] == 'FAIL_LOGIN_TOKEN' ||
payload['message'] == 'FAIL_WRONG_PASSWORD');
}
void throwIfPacketError(Packet packet) {
if (!packet.isError) return;
final payload = packet.payload;
if (isSessionExpiredPayload(payload)) {
throw SessionExpiredException(messageFromErrorPayload(payload));
}
throw PacketError(messageFromErrorPayload(payload));
}
bool isSessionStateError(Object error) {
if (error is SessionExpiredException) return true;
final text = error.toString().toLowerCase();
return text.contains('состояние сессии') ||
text.contains('сессия не найдена') ||
text.contains('авторизационная сессия') ||
text.contains('сессия не онлайн');
}
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
const int _compressionThreshold = 32;
/// Упаковка пакета для отправки на сервер.
///
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
/// которому получатель выделяет буфер под распаковку).
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
final Uint8List raw = msgpack.serialize(payload);
final List<int> body;
final int flag;
if (raw.length < _compressionThreshold) {
body = raw;
flag = 0;
} else {
body = lz4Compress(raw);
flag = (raw.length ~/ body.length) + 1;
}
final out = Uint8List(headerSize + body.length);
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
header.setUint8(0, 10);
header.setUint8(1, CmdType.request);
header.setUint16(2, seq, Endian.big);
header.setUint16(4, opcode, Endian.big);
header.setUint32(
6,
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
Endian.big,
);
out.setRange(headerSize, out.length, body);
return out;
}
const int _isolateDecodeThreshold = 4096;
Future<Packet> unpackPacket(Uint8List packet) async {
final header = ByteData.sublistView(packet);
final apiVer = header.getUint8(0) & 0xFF;
final cmd = header.getUint8(1) & 0xFF;
final seq = header.getUint16(2) & 0xFFFF;
final opcode = header.getUint16(4) & 0xFFFF;
final packedLen = header.getUint32(6);
final compFlag = packedLen >> 24;
final payloadLength = packedLen & 0xFFFFFF;
if (payloadLength == 0) {
return Packet(api: apiVer, cmd: cmd, seq: seq, opcode: opcode);
}
final end = headerSize + payloadLength;
if (end > packet.length) {
throw Exception('Packet payload length $payloadLength exceeds buffer');
}
final slice = Uint8List.sublistView(packet, headerSize, end);
dynamic payload;
if (compFlag == 0 && slice.length < _isolateDecodeThreshold) {
payload = _deserializePayload(slice, compFlag);
} else {
final owned = Uint8List.fromList(slice);
payload = await Isolate.run(() => _deserializePayload(owned, compFlag));
}
return Packet(
api: apiVer,
cmd: cmd,
seq: seq,
opcode: opcode,
payload: payload,
);
}
dynamic _deserializePayload(Uint8List payloadBytes, int compFlag) {
var bytes = payloadBytes;
if (compFlag != 0) {
bytes = _decompressPayload(bytes);
}
if (bytes.isEmpty) return null;
try {
return msgpack.deserialize(bytes);
} catch (e) {
throw Exception('MsgPack deserialization error: $e');
}
}
/// Определяет формат сжатия по magic-number и распаковывает payload.
/// Сервер может присылать LZ4 block ИЛИ Zstandard в зависимости от ответа.
Uint8List _decompressPayload(Uint8List src) {
// Zstandard: magic 28 B5 2F FD (little-endian)
if (src.length >= 4 &&
src[0] == 0x28 &&
src[1] == 0xB5 &&
src[2] == 0x2F &&
src[3] == 0xFD) {
try {
return ZstdCodec(
maxDecompressedSize: _maxDecompressedSize,
).decompress(src);
} catch (e) {
throw Exception('Zstd decompression error: $e');
}
}
// LZ4 frame: magic 04 22 4D 18
if (src.length >= 4 &&
src[0] == 0x04 &&
src[1] == 0x22 &&
src[2] == 0x4D &&
src[3] == 0x18) {
try {
return lz4Decompress(src, decompressedSize: _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 frame decompression error: $e');
}
}
// По умолчанию — LZ4 block (без magic)
try {
return lz4BlockDecompress(src, _maxDecompressedSize);
} catch (e) {
throw Exception('LZ4 block decompression error: $e');
}
}