096c4d0a2d
Серверная часть (Go): - WebSocket сервер с бинарным протоколом - XChaCha20-Poly1305 шифрование - zstd сжатие с дедупликацией (64KB чанки) - SQLite хранилище (WAL режим) - Управление гильдиями, каналами, ролями - Федерация между серверами (ed25519) - REST API + WebSocket endpoints Клиентская часть (Flutter): - Material Design 3 тёмная тема (Discord-like) - WebSocket соединение с сервером - Экраны: сплэш, логин, домашний, гильдии, чат - Модели: пользователи, гильдии, каналы, сообщения, роли - Сервисы: соединение, API, криптография, тема - Виджеты: иконки гильдий, сообщения, ввод чата - Web сборка (PWA) Документация: - AGENTS.md — контекст для ИИ ассистентов - docs/protocol.md — спецификация протокола
200 lines
4.8 KiB
Dart
200 lines
4.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
|
import '../models/message.dart';
|
|
|
|
enum ConnectionState { disconnected, connecting, connected }
|
|
|
|
class ConnectionService extends ChangeNotifier {
|
|
WebSocketChannel? _channel;
|
|
ConnectionState _state = ConnectionState.disconnected;
|
|
String _serverUrl = 'ws://localhost:8443/ws';
|
|
String? _userId;
|
|
String? _username;
|
|
String? _token;
|
|
Timer? _heartbeatTimer;
|
|
int _seq = 0;
|
|
|
|
final StreamController<Map<String, dynamic>> _messageController =
|
|
StreamController<Map<String, dynamic>>.broadcast();
|
|
|
|
Stream<Map<String, dynamic>> get messageStream => _messageController.stream;
|
|
|
|
ConnectionState get state => _state;
|
|
String? get userId => _userId;
|
|
String? get username => _username;
|
|
String? get token => _token;
|
|
bool get isConnected => _state == ConnectionState.connected;
|
|
|
|
String get serverUrl => _serverUrl;
|
|
set serverUrl(String url) {
|
|
_serverUrl = url;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
disconnect();
|
|
_messageController.close();
|
|
super.dispose();
|
|
}
|
|
|
|
void init() {
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> connect() async {
|
|
_state = ConnectionState.connecting;
|
|
notifyListeners();
|
|
|
|
try {
|
|
_channel = WebSocketChannel.connect(Uri.parse(_serverUrl));
|
|
await _channel!.ready;
|
|
|
|
_channel!.stream.listen(
|
|
(data) {
|
|
_handleMessage(data);
|
|
},
|
|
onError: (error) {
|
|
_state = ConnectionState.disconnected;
|
|
notifyListeners();
|
|
},
|
|
onDone: () {
|
|
_state = ConnectionState.disconnected;
|
|
_heartbeatTimer?.cancel();
|
|
notifyListeners();
|
|
},
|
|
);
|
|
|
|
_state = ConnectionState.connected;
|
|
_startHeartbeat();
|
|
notifyListeners();
|
|
} catch (e) {
|
|
_state = ConnectionState.disconnected;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void disconnect() {
|
|
_heartbeatTimer?.cancel();
|
|
_channel?.sink.close();
|
|
_channel = null;
|
|
_state = ConnectionState.disconnected;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _startHeartbeat() {
|
|
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
send({'op': 0});
|
|
});
|
|
}
|
|
|
|
void _handleMessage(dynamic data) {
|
|
try {
|
|
final Map<String, dynamic> packet = jsonDecode(data as String);
|
|
final int op = packet['op'] as int;
|
|
|
|
switch (op) {
|
|
case 1: // Hello
|
|
break;
|
|
case 3: // Authenticated
|
|
final d = packet['d'] as Map<String, dynamic>;
|
|
_userId = d['user_id'] as String?;
|
|
_username = d['username'] as String?;
|
|
_token = d['token'] as String?;
|
|
notifyListeners();
|
|
break;
|
|
case 10: // MessageCreate
|
|
_messageController.add(packet['d'] as Map<String, dynamic>);
|
|
break;
|
|
case 13: // Reaction
|
|
_messageController.add(packet['d'] as Map<String, dynamic>);
|
|
break;
|
|
case 40: // VoiceState
|
|
_messageController.add(packet['d'] as Map<String, dynamic>);
|
|
break;
|
|
case 60: // Typing
|
|
_messageController.add(packet['d'] as Map<String, dynamic>);
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to handle message: $e');
|
|
}
|
|
}
|
|
|
|
void send(Map<String, dynamic> packet) {
|
|
if (_channel == null) return;
|
|
packet['s'] = ++_seq;
|
|
_channel!.sink.add(jsonEncode(packet));
|
|
}
|
|
|
|
void authenticate({String? token, String? username}) {
|
|
send({
|
|
'op': 2,
|
|
'd': {
|
|
if (token != null) 'token': token,
|
|
if (username != null) 'username': username,
|
|
},
|
|
});
|
|
}
|
|
|
|
void sendMessage({
|
|
required String channelId,
|
|
required Uint8List content,
|
|
bool encrypted = false,
|
|
Uint8List? nonce,
|
|
String messageType = 'text',
|
|
String? replyTo,
|
|
}) {
|
|
send({
|
|
'op': 10,
|
|
'd': {
|
|
'channel_id': channelId,
|
|
'content': content.toList(),
|
|
'encrypted': encrypted,
|
|
if (nonce != null) 'nonce': nonce.toList(),
|
|
'message_type': messageType,
|
|
if (replyTo != null) 'reply_to': replyTo,
|
|
},
|
|
});
|
|
}
|
|
|
|
void sendReaction(String messageId, String emoji, {bool add = true}) {
|
|
send({
|
|
'op': 13,
|
|
'd': {
|
|
'message_id': messageId,
|
|
'emoji': emoji,
|
|
'add': add,
|
|
},
|
|
});
|
|
}
|
|
|
|
void updateVoiceState({
|
|
required String guildId,
|
|
String? channelId,
|
|
bool muted = false,
|
|
bool deafened = false,
|
|
}) {
|
|
send({
|
|
'op': 40,
|
|
'd': {
|
|
'guild_id': guildId,
|
|
'channel_id': channelId ?? '',
|
|
'muted': muted,
|
|
'deafened': deafened,
|
|
},
|
|
});
|
|
}
|
|
|
|
void sendTyping(String channelId) {
|
|
send({
|
|
'op': 60,
|
|
'd': {'channel_id': channelId},
|
|
});
|
|
}
|
|
}
|