🧱 Activity RPC server
This commit is contained in:
		
							
								
								
									
										572
									
								
								lib/pods/activity_rpc.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										572
									
								
								lib/pods/activity_rpc.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,572 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer' as developer; | ||||
| import 'dart:io'; | ||||
| import 'dart:typed_data'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:shelf/shelf.dart'; | ||||
| import 'package:shelf/shelf_io.dart' as shelf_io; | ||||
| import 'package:shelf_web_socket/shelf_web_socket.dart'; | ||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | ||||
| import 'package:path/path.dart' as path; | ||||
|  | ||||
| const String kRpcLogPrefix = 'arRPC.websocket'; | ||||
| const String kRpcIpcLogPrefix = 'arRPC.ipc'; | ||||
|  | ||||
| // IPC Constants | ||||
| const String kIpcBasePath = 'discord-ipc'; | ||||
|  | ||||
| // IPC Packet Types | ||||
| class IpcTypes { | ||||
|   static const int handshake = 0; | ||||
|   static const int frame = 1; | ||||
|   static const int close = 2; | ||||
|   static const int ping = 3; | ||||
|   static const int pong = 4; | ||||
| } | ||||
|  | ||||
| // IPC Close Codes | ||||
| class IpcCloseCodes { | ||||
|   static const int closeNormal = 1000; | ||||
|   static const int closeUnsupported = 1003; | ||||
|   static const int closeAbnormal = 1006; | ||||
| } | ||||
|  | ||||
| // IPC Error Codes | ||||
| class IpcErrorCodes { | ||||
|   static const int invalidClientId = 4000; | ||||
|   static const int invalidOrigin = 4001; | ||||
|   static const int rateLimited = 4002; | ||||
|   static const int tokenRevoked = 4003; | ||||
|   static const int invalidVersion = 4004; | ||||
|   static const int invalidEncoding = 4005; | ||||
| } | ||||
|  | ||||
| class ActivityRpcServer { | ||||
|   static const List<int> portRange = [6463, 6472]; // Ports 6463–6472 | ||||
|   Map<String, Function> | ||||
|   handlers; // {connection: (socket), message: (socket, data), close: (socket)} | ||||
|   HttpServer? _httpServer; | ||||
|   ServerSocket? _ipcServer; | ||||
|   final List<WebSocketChannel> _wsSockets = []; | ||||
|   final List<_IpcSocketWrapper> _ipcSockets = []; | ||||
|  | ||||
|   ActivityRpcServer(this.handlers); | ||||
|  | ||||
|   void updateHandlers(Map<String, Function> newHandlers) { | ||||
|     handlers = newHandlers; | ||||
|   } | ||||
|  | ||||
|   // Encode IPC packet | ||||
|   static Uint8List encodeIpcPacket(int type, Map<String, dynamic> data) { | ||||
|     final jsonData = jsonEncode(data); | ||||
|     final dataBytes = utf8.encode(jsonData); | ||||
|     final dataSize = dataBytes.length; | ||||
|  | ||||
|     final buffer = ByteData(8 + dataSize); | ||||
|     buffer.setInt32(0, type, Endian.little); | ||||
|     buffer.setInt32(4, dataSize, Endian.little); | ||||
|     buffer.buffer.asUint8List().setRange(8, 8 + dataSize, dataBytes); | ||||
|  | ||||
|     return buffer.buffer.asUint8List(); | ||||
|   } | ||||
|  | ||||
|   Future<String> _getMacOsSystemTmpDir() async { | ||||
|     final result = await Process.run('getconf', ['DARWIN_USER_TEMP_DIR']); | ||||
|     return (result.stdout as String).trim(); | ||||
|   } | ||||
|  | ||||
|   // Find available IPC socket path | ||||
|   Future<String> _findAvailableIpcPath() async { | ||||
|     final baseDirs = [ | ||||
|       if (Platform.isMacOS) await _getMacOsSystemTmpDir(), | ||||
|       Platform.environment['XDG_RUNTIME_DIR'], // User runtime directory | ||||
|       Platform.environment['TMPDIR'], // App container temp (fallback) | ||||
|       Platform.environment['TMP'], | ||||
|       Platform.environment['TEMP'], | ||||
|       '/temp', | ||||
|       '/tmp', // System temp directory - most compatible | ||||
|     ]; | ||||
|  | ||||
|     for (final baseDir in baseDirs) { | ||||
|       if (baseDir == null || baseDir.isEmpty) continue; | ||||
|       for (int i = 0; i < 10; i++) { | ||||
|         final socketPath = path.join(baseDir, '$kIpcBasePath-$i'); | ||||
|         try { | ||||
|           final socket = await ServerSocket.bind( | ||||
|             InternetAddress(socketPath, type: InternetAddressType.unix), | ||||
|             0, | ||||
|           ); | ||||
|           socket.close(); | ||||
|           // Clean up the test socket | ||||
|           try { | ||||
|             await File(socketPath).delete(); | ||||
|           } catch (_) {} | ||||
|           developer.log( | ||||
|             'IPC socket will be created at: $socketPath', | ||||
|             name: kRpcIpcLogPrefix, | ||||
|           ); | ||||
|           return socketPath; | ||||
|         } catch (e) { | ||||
|           // Path not available, try next | ||||
|           if (i == 0) { | ||||
|             // Log only for the first attempt per directory | ||||
|             developer.log( | ||||
|               'IPC path $socketPath not available: $e', | ||||
|               name: kRpcIpcLogPrefix, | ||||
|             ); | ||||
|           } | ||||
|           continue; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     throw Exception( | ||||
|       'No available IPC socket paths found in any temp directory', | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Start the WebSocket server | ||||
|   Future<void> start() async { | ||||
|     int port = portRange[0]; | ||||
|     bool wsSuccess = false; | ||||
|  | ||||
|     // Start WebSocket server | ||||
|     while (port <= portRange[1]) { | ||||
|       developer.log('trying port $port', name: kRpcLogPrefix); | ||||
|       try { | ||||
|         // Start HTTP server | ||||
|         _httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port); | ||||
|         developer.log('listening on $port', name: kRpcLogPrefix); | ||||
|  | ||||
|         // Handle WebSocket upgrades | ||||
|         shelf_io.serveRequests(_httpServer!, (Request request) async { | ||||
|           developer.log('new request', name: kRpcLogPrefix); | ||||
|           if (request.headers['upgrade']?.toLowerCase() == 'websocket') { | ||||
|             final handler = webSocketHandler((WebSocketChannel channel) { | ||||
|               _wsSockets.add(channel); | ||||
|               _onWsConnection(channel, request); | ||||
|             }); | ||||
|             return handler(request); | ||||
|           } | ||||
|           developer.log( | ||||
|             'new request disposed due to not websocket', | ||||
|             name: kRpcLogPrefix, | ||||
|           ); | ||||
|           return Response.notFound('Not a WebSocket request'); | ||||
|         }); | ||||
|         wsSuccess = true; | ||||
|         break; | ||||
|       } catch (e) { | ||||
|         if (e is SocketException && e.osError?.errorCode == 98) { | ||||
|           // EADDRINUSE | ||||
|           developer.log('$port in use!', name: kRpcLogPrefix); | ||||
|         } else { | ||||
|           developer.log('http error: $e', name: kRpcLogPrefix); | ||||
|         } | ||||
|         port++; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (!wsSuccess) { | ||||
|       throw Exception( | ||||
|         'Failed to bind to any port in range ${portRange[0]}–${portRange[1]}', | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Start IPC server | ||||
|     try { | ||||
|       final ipcPath = await _findAvailableIpcPath(); | ||||
|       _ipcServer = await ServerSocket.bind( | ||||
|         InternetAddress(ipcPath, type: InternetAddressType.unix), | ||||
|         0, | ||||
|       ); | ||||
|       developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|       _ipcServer!.listen((Socket socket) { | ||||
|         _onIpcConnection(socket); | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       developer.log('IPC server error: $e', name: kRpcIpcLogPrefix); | ||||
|       // Continue without IPC if it fails | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Stop the server | ||||
|   Future<void> stop() async { | ||||
|     // Stop WebSocket server | ||||
|     for (var socket in _wsSockets) { | ||||
|       await socket.sink.close(); | ||||
|     } | ||||
|     _wsSockets.clear(); | ||||
|     await _httpServer?.close(); | ||||
|  | ||||
|     // Stop IPC server | ||||
|     for (var socket in _ipcSockets) { | ||||
|       socket.close(); | ||||
|     } | ||||
|     _ipcSockets.clear(); | ||||
|     await _ipcServer?.close(); | ||||
|  | ||||
|     developer.log('servers stopped', name: kRpcLogPrefix); | ||||
|   } | ||||
|  | ||||
|   // Handle new WebSocket connection | ||||
|   void _onWsConnection(WebSocketChannel socket, Request request) { | ||||
|     // Parse query parameters | ||||
|     final uri = request.url; | ||||
|     final params = uri.queryParameters; | ||||
|     final ver = int.tryParse(params['v'] ?? '1') ?? 1; | ||||
|     final encoding = params['encoding'] ?? 'json'; | ||||
|     final clientId = params['client_id'] ?? ''; | ||||
|     final origin = request.headers['origin'] ?? ''; | ||||
|  | ||||
|     developer.log( | ||||
|       'new WS connection! origin: $origin, params: $params', | ||||
|       name: kRpcLogPrefix, | ||||
|     ); | ||||
|  | ||||
|     // Validate origin | ||||
|     if (origin.isNotEmpty && | ||||
|         ![ | ||||
|           'https://discord.com', | ||||
|           'https://ptb.discord.com', | ||||
|           'https://canary.discord.com', | ||||
|         ].contains(origin)) { | ||||
|       developer.log('disallowed origin: $origin', name: kRpcLogPrefix); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate encoding | ||||
|     if (encoding != 'json') { | ||||
|       developer.log( | ||||
|         'unsupported encoding requested: $encoding', | ||||
|         name: kRpcLogPrefix, | ||||
|       ); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate version | ||||
|     if (ver != 1) { | ||||
|       developer.log('unsupported version requested: $ver', name: kRpcLogPrefix); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Store client info on socket | ||||
|     final socketWithMeta = _WsSocketWrapper(socket, clientId, encoding); | ||||
|  | ||||
|     // Set up event listeners | ||||
|     socket.stream.listen( | ||||
|       (data) => _onWsMessage(socketWithMeta, data), | ||||
|       onError: (e) { | ||||
|         developer.log('WS socket error: $e', name: kRpcLogPrefix); | ||||
|       }, | ||||
|       onDone: () { | ||||
|         developer.log('WS socket closed', name: kRpcLogPrefix); | ||||
|         handlers['close']?.call(socketWithMeta); | ||||
|         _wsSockets.remove(socket); | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     // Notify handler of new connection | ||||
|     handlers['connection']?.call(socketWithMeta); | ||||
|   } | ||||
|  | ||||
|   // Handle new IPC connection | ||||
|   void _onIpcConnection(Socket socket) { | ||||
|     developer.log('new IPC connection!', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|     final socketWrapper = _IpcSocketWrapper(socket); | ||||
|     _ipcSockets.add(socketWrapper); | ||||
|  | ||||
|     // Set up event listeners | ||||
|     socket.listen( | ||||
|       (data) => _onIpcData(socketWrapper, data), | ||||
|       onError: (e) { | ||||
|         developer.log('IPC socket error: $e', name: kRpcIpcLogPrefix); | ||||
|         socket.close(); | ||||
|       }, | ||||
|       onDone: () { | ||||
|         developer.log('IPC socket closed', name: kRpcIpcLogPrefix); | ||||
|         handlers['close']?.call(socketWrapper); | ||||
|         _ipcSockets.remove(socketWrapper); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Handle incoming WebSocket message | ||||
|   void _onWsMessage(_WsSocketWrapper socket, dynamic data) { | ||||
|     try { | ||||
|       final jsonData = jsonDecode(data as String); | ||||
|       developer.log('WS message: $jsonData', name: kRpcLogPrefix); | ||||
|       handlers['message']?.call(socket, jsonData); | ||||
|     } catch (e) { | ||||
|       developer.log('WS message parse error: $e', name: kRpcLogPrefix); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Handle incoming IPC data | ||||
|   void _onIpcData(_IpcSocketWrapper socket, List<int> data) { | ||||
|     try { | ||||
|       socket.addData(data); | ||||
|       final packets = socket.readPackets(); | ||||
|       for (final packet in packets) { | ||||
|         _handleIpcPacket(socket, packet); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       developer.log('IPC data error: $e', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString()); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Handle IPC packet | ||||
|   void _handleIpcPacket(_IpcSocketWrapper socket, _IpcPacket packet) { | ||||
|     switch (packet.type) { | ||||
|       case IpcTypes.ping: | ||||
|         developer.log('IPC ping received', name: kRpcIpcLogPrefix); | ||||
|         socket.sendPong(packet.data); | ||||
|         break; | ||||
|  | ||||
|       case IpcTypes.pong: | ||||
|         developer.log('IPC pong received', name: kRpcIpcLogPrefix); | ||||
|         // Handle pong if needed | ||||
|         break; | ||||
|  | ||||
|       case IpcTypes.handshake: | ||||
|         if (socket.handshook) { | ||||
|           throw Exception('Already handshook'); | ||||
|         } | ||||
|         socket.handshook = true; | ||||
|         _onIpcHandshake(socket, packet.data); | ||||
|         break; | ||||
|  | ||||
|       case IpcTypes.frame: | ||||
|         if (!socket.handshook) { | ||||
|           throw Exception('Need to handshake first'); | ||||
|         } | ||||
|         developer.log('IPC frame: ${packet.data}', name: kRpcIpcLogPrefix); | ||||
|         handlers['message']?.call(socket, packet.data); | ||||
|         break; | ||||
|  | ||||
|       case IpcTypes.close: | ||||
|         socket.close(); | ||||
|         break; | ||||
|  | ||||
|       default: | ||||
|         throw Exception('Invalid packet type: ${packet.type}'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Handle IPC handshake | ||||
|   void _onIpcHandshake(_IpcSocketWrapper socket, Map<String, dynamic> params) { | ||||
|     developer.log('IPC handshake: $params', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|     final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1; | ||||
|     final clientId = params['client_id']?.toString() ?? ''; | ||||
|  | ||||
|     // Validate version | ||||
|     if (ver != 1) { | ||||
|       developer.log( | ||||
|         'IPC unsupported version requested: $ver', | ||||
|         name: kRpcIpcLogPrefix, | ||||
|       ); | ||||
|       socket.closeWithCode(IpcErrorCodes.invalidVersion); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate client ID | ||||
|     if (clientId.isEmpty) { | ||||
|       developer.log('IPC client ID required', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcErrorCodes.invalidClientId); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     socket.clientId = clientId; | ||||
|  | ||||
|     // Notify handler of new connection | ||||
|     handlers['connection']?.call(socket); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // WebSocket wrapper | ||||
| class _WsSocketWrapper { | ||||
|   final WebSocketChannel channel; | ||||
|   final String clientId; | ||||
|   final String encoding; | ||||
|  | ||||
|   _WsSocketWrapper(this.channel, this.clientId, this.encoding); | ||||
|  | ||||
|   void send(Map<String, dynamic> msg) { | ||||
|     developer.log('WS sending: $msg', name: kRpcLogPrefix); | ||||
|     channel.sink.add(jsonEncode(msg)); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // IPC wrapper | ||||
| class _IpcSocketWrapper { | ||||
|   final Socket socket; | ||||
|   String clientId = ''; | ||||
|   bool handshook = false; | ||||
|   final List<int> _buffer = []; | ||||
|  | ||||
|   _IpcSocketWrapper(this.socket); | ||||
|  | ||||
|   void addData(List<int> data) { | ||||
|     _buffer.addAll(data); | ||||
|   } | ||||
|  | ||||
|   void send(Map<String, dynamic> msg) { | ||||
|     developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix); | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.frame, msg); | ||||
|     socket.add(packet); | ||||
|   } | ||||
|  | ||||
|   void sendPong(dynamic data) { | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {}); | ||||
|     socket.add(packet); | ||||
|   } | ||||
|  | ||||
|   void close() { | ||||
|     socket.close(); | ||||
|   } | ||||
|  | ||||
|   void closeWithCode(int code, [String message = '']) { | ||||
|     final closeData = {'code': code, 'message': message}; | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.close, closeData); | ||||
|     socket.add(packet); | ||||
|     socket.close(); | ||||
|   } | ||||
|  | ||||
|   List<_IpcPacket> readPackets() { | ||||
|     final packets = <_IpcPacket>[]; | ||||
|  | ||||
|     while (_buffer.length >= 8) { | ||||
|       final buffer = Uint8List.fromList(_buffer); | ||||
|       final byteData = ByteData.view(buffer.buffer); | ||||
|  | ||||
|       final type = byteData.getInt32(0, Endian.little); | ||||
|       final dataSize = byteData.getInt32(4, Endian.little); | ||||
|  | ||||
|       if (_buffer.length < 8 + dataSize) break; | ||||
|  | ||||
|       final dataBytes = _buffer.sublist(8, 8 + dataSize); | ||||
|       final jsonStr = utf8.decode(dataBytes); | ||||
|       final jsonData = jsonDecode(jsonStr); | ||||
|  | ||||
|       packets.add(_IpcPacket(type, jsonData)); | ||||
|  | ||||
|       _buffer.removeRange(0, 8 + dataSize); | ||||
|     } | ||||
|  | ||||
|     return packets; | ||||
|   } | ||||
| } | ||||
|  | ||||
| // IPC Packet structure | ||||
| class _IpcPacket { | ||||
|   final int type; | ||||
|   final Map<String, dynamic> data; | ||||
|  | ||||
|   _IpcPacket(this.type, this.data); | ||||
| } | ||||
|  | ||||
| // State management for server status and activities | ||||
| class ServerState { | ||||
|   final String status; | ||||
|   final List<String> activities; | ||||
|  | ||||
|   ServerState({required this.status, this.activities = const []}); | ||||
|  | ||||
|   ServerState copyWith({String? status, List<String>? activities}) { | ||||
|     return ServerState( | ||||
|       status: status ?? this.status, | ||||
|       activities: activities ?? this.activities, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class ServerStateNotifier extends StateNotifier<ServerState> { | ||||
|   final ActivityRpcServer server; | ||||
|  | ||||
|   ServerStateNotifier(this.server) | ||||
|     : super(ServerState(status: 'Server not started')); | ||||
|  | ||||
|   Future<void> start() async { | ||||
|     try { | ||||
|       await server.start(); | ||||
|       state = state.copyWith(status: 'Server running'); | ||||
|     } catch (e) { | ||||
|       state = state.copyWith(status: 'Server failed: $e'); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void updateStatus(String status) { | ||||
|     state = state.copyWith(status: status); | ||||
|   } | ||||
|  | ||||
|   void addActivity(String activity) { | ||||
|     state = state.copyWith(activities: [...state.activities, activity]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Providers | ||||
| final rpcServerStateProvider = | ||||
|     StateNotifierProvider<ServerStateNotifier, ServerState>((ref) { | ||||
|       final server = ActivityRpcServer({}); | ||||
|       final notifier = ServerStateNotifier(server); | ||||
|       server.updateHandlers({ | ||||
|         'connection': (socket) { | ||||
|           final clientId = | ||||
|               socket is _WsSocketWrapper | ||||
|                   ? socket.clientId | ||||
|                   : (socket as _IpcSocketWrapper).clientId; | ||||
|           notifier.updateStatus('Client connected (ID: $clientId)'); | ||||
|           // Send READY event | ||||
|           socket.send({ | ||||
|             'cmd': 'DISPATCH', | ||||
|             'data': { | ||||
|               'v': 1, | ||||
|               'config': { | ||||
|                 'cdn_host': 'fake.cdn', | ||||
|                 'api_endpoint': '//fake.api', | ||||
|                 'environment': 'dev', | ||||
|               }, | ||||
|               'user': { | ||||
|                 'id': 'fake_user_id', | ||||
|                 'username': 'FakeUser', | ||||
|                 'discriminator': '0001', | ||||
|                 'avatar': null, | ||||
|                 'bot': false, | ||||
|               }, | ||||
|             }, | ||||
|             'evt': 'READY', | ||||
|             'nonce': '12345', // Should be dynamic | ||||
|           }); | ||||
|         }, | ||||
|         'message': (socket, dynamic data) { | ||||
|           if (data['cmd'] == 'SET_ACTIVITY') { | ||||
|             notifier.addActivity( | ||||
|               'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', | ||||
|             ); | ||||
|             // Echo back success | ||||
|             socket.send({ | ||||
|               'cmd': 'SET_ACTIVITY', | ||||
|               'data': data['args']['activity'], | ||||
|               'evt': null, | ||||
|               'nonce': data['nonce'], | ||||
|             }); | ||||
|           } | ||||
|         }, | ||||
|         'close': (socket) { | ||||
|           notifier.updateStatus('Client disconnected'); | ||||
|         }, | ||||
|       }); | ||||
|       return notifier; | ||||
|     }); | ||||
|  | ||||
| final rpcServerProvider = Provider<ActivityRpcServer>((ref) { | ||||
|   final notifier = ref.watch(rpcServerStateProvider.notifier); | ||||
|   return notifier.server; | ||||
| }); | ||||
		Reference in New Issue
	
	Block a user