From f67ca187a8897cc5b607d19bff212eab66d47b93 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 3 Feb 2026 16:51:01 +0100 Subject: [PATCH] feat: replace webrtc with ws relay --- ios/Podfile.lock | 12 +- .../providers/watch_together_provider.dart | 11 + .../services/watch_together_peer_service.dart | 583 +++++++++--------- .../services/watch_together_sync_manager.dart | 49 +- linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 - macos/Podfile.lock | 10 - macos/Runner.xcodeproj/project.pbxproj | 148 ++--- pubspec.lock | 42 +- pubspec.yaml | 5 +- server/go.mod | 13 + server/go.sum | 8 + server/main.go | 388 ++++++++++++ .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - 16 files changed, 826 insertions(+), 454 deletions(-) create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go diff --git a/ios/Podfile.lock b/ios/Podfile.lock index e212c3a6..476cc315 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -9,8 +9,6 @@ PODS: - flutter_webrtc (1.2.0): - Flutter - WebRTC-SDK (= 137.7151.04) - - gamepads_ios (0.1.1): - - Flutter - in_app_review (2.0.0): - Flutter - os_media_controls (0.0.1): @@ -51,6 +49,8 @@ PODS: - sqlite3/perf-threadsafe - sqlite3/rtree - sqlite3/session + - universal_gamepad (0.1.0): + - Flutter - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -65,7 +65,6 @@ DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - - gamepads_ios (from `.symlinks/plugins/gamepads_ios/ios`) - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - os_media_controls (from `.symlinks/plugins/os_media_controls/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -73,6 +72,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) @@ -93,8 +93,6 @@ EXTERNAL SOURCES: :path: Flutter flutter_webrtc: :path: ".symlinks/plugins/flutter_webrtc/ios" - gamepads_ios: - :path: ".symlinks/plugins/gamepads_ios/ios" in_app_review: :path: ".symlinks/plugins/in_app_review/ios" os_media_controls: @@ -109,6 +107,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" sqlite3_flutter_libs: :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" + universal_gamepad: + :path: ".symlinks/plugins/universal_gamepad/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" wakelock_plus: @@ -122,7 +122,6 @@ SPEC CHECKSUMS: file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22 - gamepads_ios: c75c6d31377d275b0effb9174c619e2705678c09 in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937 os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 @@ -131,6 +130,7 @@ SPEC CHECKSUMS: sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqlite3: 8d708bc63e9f4ce48f0ad9d6269e478c5ced1d9b sqlite3_flutter_libs: d13b8b3003f18f596e542bcb9482d105577eff41 + universal_gamepad: e10172778a8a399cce234494968f38724974919e url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 088112cb..4b971a72 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -302,6 +302,17 @@ class WatchTogetherProvider with ChangeNotifier { ); } + // If we're the host, send our join info back so the new peer + // adds us to their participant list. This is done at provider + // level (in addition to sync manager) so it works even when + // no player is attached yet. + if (isHost && _peerService != null) { + _peerService!.sendTo( + message.peerId!, + SyncMessage.join(peerId: _peerService!.myPeerId!, displayName: _displayName, isHost: true), + ); + } + notifyListeners(); } break; diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index f9fff5f4..5014fd79 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -1,7 +1,8 @@ import 'dart:async'; +import 'dart:convert'; -import 'package:peerdart/peerdart.dart'; import 'package:uuid/uuid.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import '../../utils/app_logger.dart'; import '../models/sync_message.dart'; @@ -21,16 +22,19 @@ class PeerError { String toString() => 'PeerError($type): $message'; } -/// Service for managing WebRTC peer connections using PeerJS +/// Service for managing Watch Together connections via a WebSocket relay /// /// This service handles: /// - Creating sessions (as host) /// - Joining sessions (as guest) -/// - Sending/receiving sync messages over data channels -/// - Managing multiple peer connections +/// - Sending/receiving sync messages through the relay server +/// - Reconnection on WebSocket drops class WatchTogetherPeerService { - Peer? _peer; - final Map _connections = {}; + static const String _relayUrl = 'wss://ice.plezy.app/relay'; + + WebSocketChannel? _channel; + StreamSubscription? _channelSubscription; + final Set _connectedPeers = {}; String? _sessionId; String? _myPeerId; bool _isHost = false; @@ -42,18 +46,16 @@ class WatchTogetherPeerService { final _errorController = StreamController.broadcast(); final _connectionStateController = StreamController.broadcast(); - // Reconnection state (signaling server) + // Reconnection state int _reconnectAttempts = 0; static const int _maxReconnectAttempts = 3; Timer? _reconnectTimer; - // Peer health monitoring (data channel) - final Map _lastPeerActivity = {}; - final Map _peerReconnectAttempts = {}; - Timer? _peerHealthCheckTimer; - static const Duration _peerTimeout = Duration(seconds: 30); - static const Duration _peerHealthCheckInterval = Duration(seconds: 10); - static const int _maxPeerReconnectAttempts = 3; + // Keepalive + Timer? _pingTimer; + Timer? _pongTimer; + static const Duration _pingInterval = Duration(seconds: 15); + static const Duration _pongTimeout = Duration(seconds: 30); /// Stream of peer IDs when a new peer connects Stream get onPeerConnected => _peerConnectedController.stream; @@ -80,334 +82,322 @@ class WatchTogetherPeerService { bool get isHost => _isHost; /// Whether currently connected to a session - bool get isConnected => _peer != null && _connections.isNotEmpty; + bool get isConnected => _channel != null && _connectedPeers.isNotEmpty; /// List of connected peer IDs - List get connectedPeers => _connections.keys.toList(); + List get connectedPeers => _connectedPeers.toList(); /// Generate a short, readable session ID String _generateSessionId() { - // Use first 8 characters of UUID for readability return const Uuid().v4().substring(0, 8).toUpperCase(); } - /// Attach common peer event listeners for disconnected/close/error events - void _attachCommonPeerListeners({ - required Completer completer, - required PeerErrorType errorType, - required String errorMessage, - }) { - _peer!.on('disconnected').listen((_) { - appLogger.w('WatchTogether: Peer disconnected from server'); - _handleDisconnectedFromServer(); - }); + /// Connect to the relay WebSocket and set up the message listener. + /// Returns a completer that completes when the expected response arrives. + Future _connectToRelay() async { + final uri = Uri.parse(_relayUrl); + final channel = WebSocketChannel.connect(uri); - _peer!.on('close').listen((_) { - appLogger.d('WatchTogether: Peer closed'); - _connectionStateController.add(false); - }); + // Wait for the connection to be established + await channel.ready; - _peer!.on('error').listen((error) { - appLogger.e('WatchTogether: Peer error', error: error); - _errorController.add(PeerError(type: errorType, message: '$errorMessage: $error', originalError: error)); - if (!completer.isCompleted) { - completer.completeError(error); + return channel; + } + + /// Listen on the channel stream and route incoming server messages. + void _listenToChannel(WebSocketChannel channel, {Completer? setupCompleter}) { + _channelSubscription?.cancel(); + _channelSubscription = channel.stream.listen( + (data) { + _resetPongTimer(); + _handleServerMessage(data as String, setupCompleter: setupCompleter); + }, + onError: (error) { + appLogger.e('WatchTogether: WebSocket error', error: error); + _errorController.add( + PeerError(type: PeerErrorType.serverError, message: 'WebSocket error: $error', originalError: error), + ); + if (setupCompleter != null && !setupCompleter.isCompleted) { + setupCompleter.completeError(error); + } + _handleWebSocketClosed(); + }, + onDone: () { + appLogger.w('WatchTogether: WebSocket closed'); + if (setupCompleter != null && !setupCompleter.isCompleted) { + setupCompleter.completeError( + const PeerError(type: PeerErrorType.connectionFailed, message: 'WebSocket closed before setup completed'), + ); + } + _handleWebSocketClosed(); + }, + ); + } + + /// Handle an incoming server message (JSON string). + void _handleServerMessage(String raw, {Completer? setupCompleter}) { + try { + final msg = jsonDecode(raw) as Map; + final type = msg['type'] as String?; + + switch (type) { + case 'created': + appLogger.d('WatchTogether: Room created: ${msg['sessionId']}'); + _connectionStateController.add(true); + if (setupCompleter != null && !setupCompleter.isCompleted) { + setupCompleter.complete(); + } + + case 'joined': + final peers = (msg['peers'] as List?)?.cast() ?? []; + appLogger.d('WatchTogether: Joined room ${msg['sessionId']} with peers: $peers'); + for (final peerId in peers) { + _connectedPeers.add(peerId); + _peerConnectedController.add(peerId); + } + _connectionStateController.add(true); + if (setupCompleter != null && !setupCompleter.isCompleted) { + setupCompleter.complete(); + } + + case 'peerJoined': + final peerId = msg['peerId'] as String; + appLogger.d('WatchTogether: Peer joined: $peerId'); + _connectedPeers.add(peerId); + _peerConnectedController.add(peerId); + _connectionStateController.add(true); + + case 'peerLeft': + final peerId = msg['peerId'] as String; + appLogger.d('WatchTogether: Peer left: $peerId'); + _connectedPeers.remove(peerId); + _peerDisconnectedController.add(peerId); + if (_connectedPeers.isEmpty) { + _connectionStateController.add(false); + } + + case 'message': + final from = msg['from'] as String?; + final payload = msg['payload']; + if (payload != null) { + try { + final payloadStr = payload is String ? payload : jsonEncode(payload); + final syncMsg = SyncMessage.fromJson(payloadStr); + appLogger.d('WatchTogether: Received ${syncMsg.type} from $from'); + _messageReceivedController.add(syncMsg); + } catch (e) { + appLogger.e('WatchTogether: Failed to parse sync message payload', error: e); + } + } + + case 'error': + final code = msg['code'] as String? ?? 'unknown'; + final message = msg['message'] as String? ?? 'Unknown error'; + appLogger.e('WatchTogether: Server error: $code - $message'); + final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message'); + _errorController.add(error); + if (setupCompleter != null && !setupCompleter.isCompleted) { + setupCompleter.completeError(error); + } + + case 'pong': + // Handled by _resetPongTimer already + break; + + default: + appLogger.w('WatchTogether: Unknown server message type: $type'); + } + } catch (e) { + appLogger.e('WatchTogether: Failed to parse server message', error: e); + } + } + + /// Start the keepalive ping timer. + void _startPingTimer() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(_pingInterval, (_) { + _sendRaw({'type': 'ping'}); + }); + _resetPongTimer(); + } + + /// Reset the pong timeout timer (called on every incoming message). + void _resetPongTimer() { + _pongTimer?.cancel(); + _pongTimer = Timer(_pongTimeout, () { + appLogger.w('WatchTogether: Pong timeout — closing WebSocket'); + _channel?.sink.close(); + }); + } + + /// Stop keepalive timers. + void _stopTimers() { + _pingTimer?.cancel(); + _pingTimer = null; + _pongTimer?.cancel(); + _pongTimer = null; + } + + /// Send a raw JSON map to the relay. + void _sendRaw(Map msg) { + try { + _channel?.sink.add(jsonEncode(msg)); + } catch (e) { + appLogger.e('WatchTogether: Failed to send message', error: e); + } + } + + /// Handle the WebSocket being closed unexpectedly — attempt reconnection. + void _handleWebSocketClosed() { + _stopTimers(); + _channelSubscription?.cancel(); + _channelSubscription = null; + _channel = null; + + // Notify peers lost + for (final peerId in _connectedPeers.toList()) { + _peerDisconnectedController.add(peerId); + } + _connectedPeers.clear(); + _connectionStateController.add(false); + + // Attempt to reconnect if we had a session + if (_sessionId != null) { + _attemptReconnect(); + } + } + + /// Attempt to reconnect to the relay and re-join/re-create the room. + void _attemptReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + appLogger.e('WatchTogether: Max reconnect attempts reached'); + _errorController.add( + const PeerError( + type: PeerErrorType.connectionFailed, + message: 'Lost connection to relay after multiple reconnect attempts', + ), + ); + return; + } + + _reconnectAttempts++; + final delay = Duration(seconds: _reconnectAttempts * 2); + appLogger.d('WatchTogether: Reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s'); + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(delay, () async { + try { + final channel = await _connectToRelay(); + _channel = channel; + _reconnectAttempts = 0; + + final completer = Completer(); + _listenToChannel(channel, setupCompleter: completer); + _startPingTimer(); + + // Re-send create or join + if (_isHost) { + _sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId}); + } else { + _sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId}); + } + + await completer.future.timeout(const Duration(seconds: 10)); + appLogger.d('WatchTogether: Reconnected successfully'); + } catch (e) { + appLogger.e('WatchTogether: Reconnect failed', error: e); + _handleWebSocketClosed(); } }); } /// Create a new session as host /// - /// Returns the session ID that others can use to join + /// Returns the session ID that others can use to join. Future createSession() async { - if (_peer != null) { + if (_channel != null) { await disconnect(); } _isHost = true; _sessionId = _generateSessionId(); + _myPeerId = 'wt-$_sessionId'; _reconnectAttempts = 0; - // Create peer with session ID as the peer ID so guests can connect directly - final completer = Completer(); - try { - _peer = Peer(id: 'wt-$_sessionId'); + final channel = await _connectToRelay(); + _channel = channel; - _peer!.on('open').listen((id) { - _myPeerId = id as String; - appLogger.d('WatchTogether: Host peer opened with ID: $_myPeerId'); - _connectionStateController.add(true); - if (!completer.isCompleted) { - completer.complete(_sessionId); - } - }); + final completer = Completer(); + _listenToChannel(channel, setupCompleter: completer); + _startPingTimer(); - _peer!.on('connection').listen((conn) { - final dataConn = conn as DataConnection; - _handleNewConnection(dataConn); - }); + _sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId}); - _attachCommonPeerListeners( - completer: completer, - errorType: PeerErrorType.serverError, - errorMessage: 'Server error', + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session'); + }, ); - } catch (e) { - appLogger.e('WatchTogether: Failed to create peer', error: e); - if (!completer.isCompleted) { - completer.completeError(e); - } - } - // Timeout after 10 seconds - return completer.future.timeout( - const Duration(seconds: 10), - onTimeout: () { - throw PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session'); - }, - ); + appLogger.d('WatchTogether: Session created: $_sessionId'); + return _sessionId!; + } catch (e) { + appLogger.e('WatchTogether: Failed to create session', error: e); + await disconnect(); + rethrow; + } } - /// Join an existing session as guest + /// Join an existing session as guest. Future joinSession(String sessionId) async { - if (_peer != null) { + if (_channel != null) { await disconnect(); } _isHost = false; _sessionId = sessionId.toUpperCase(); + _myPeerId = const Uuid().v4(); _reconnectAttempts = 0; - final completer = Completer(); - try { - // Create a random peer ID for guest - _peer = Peer(); + final channel = await _connectToRelay(); + _channel = channel; - _peer!.on('open').listen((id) { - _myPeerId = id as String; - appLogger.d('WatchTogether: Guest peer opened with ID: $_myPeerId'); + final completer = Completer(); + _listenToChannel(channel, setupCompleter: completer); + _startPingTimer(); - // Connect to the host - final hostPeerId = 'wt-$_sessionId'; - appLogger.d('WatchTogether: Connecting to host: $hostPeerId'); + _sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId}); - final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true)); - _handleNewConnection(conn, isOutgoing: true, completer: completer); - }); - - _attachCommonPeerListeners( - completer: completer, - errorType: PeerErrorType.connectionFailed, - errorMessage: 'Failed to connect to session', + await completer.future.timeout( + const Duration(seconds: 10), + onTimeout: () { + throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session'); + }, ); + + appLogger.d('WatchTogether: Joined session: $_sessionId'); } catch (e) { - appLogger.e('WatchTogether: Failed to create peer for joining', error: e); - if (!completer.isCompleted) { - completer.completeError(e); - } + appLogger.e('WatchTogether: Failed to join session', error: e); + await disconnect(); + rethrow; } - - // Timeout after 15 seconds - return completer.future.timeout( - const Duration(seconds: 15), - onTimeout: () { - throw PeerError(type: PeerErrorType.timeout, message: 'Timed out joining session'); - }, - ); - } - - /// Handle a new data connection (incoming or outgoing) - void _handleNewConnection(DataConnection conn, {bool isOutgoing = false, Completer? completer}) { - final peerId = conn.peer; - appLogger.d('WatchTogether: New connection ${isOutgoing ? "to" : "from"}: $peerId'); - - conn.on('open').listen((_) { - appLogger.d('WatchTogether: Data channel opened with: $peerId'); - _connections[peerId] = conn; - _updatePeerActivity(peerId); // Track initial activity - _peerConnectedController.add(peerId); - _connectionStateController.add(true); - - // Start health monitoring if not already running - if (_peerHealthCheckTimer == null) { - _startPeerHealthCheck(); - } - - if (completer != null && !completer.isCompleted) { - completer.complete(); - } - }); - - conn.on('data').listen((data) { - try { - _updatePeerActivity(peerId); // Track activity on each message - final message = SyncMessage.fromJson(data as String); - appLogger.d('WatchTogether: Received message: ${message.type} from $peerId'); - _messageReceivedController.add(message); - } catch (e) { - appLogger.e('WatchTogether: Failed to parse message', error: e); - } - }); - - conn.on('close').listen((_) { - appLogger.d('WatchTogether: Connection closed with: $peerId'); - _connections.remove(peerId); - _lastPeerActivity.remove(peerId); - _peerReconnectAttempts.remove(peerId); - _peerDisconnectedController.add(peerId); - - if (_connections.isEmpty) { - _stopPeerHealthCheck(); - _connectionStateController.add(false); - } - }); - - conn.on('error').listen((error) { - appLogger.e('WatchTogether: Connection error with $peerId', error: error); - _errorController.add( - PeerError( - type: PeerErrorType.dataChannelError, - message: 'Connection error with peer: $error', - originalError: error, - ), - ); - - // Attempt reconnection on data channel error - _attemptPeerReconnect(peerId); - }); - } - - /// Handle disconnection from PeerJS server - void _handleDisconnectedFromServer() { - if (_reconnectAttempts < _maxReconnectAttempts) { - _reconnectAttempts++; - final delay = Duration(seconds: _reconnectAttempts * 2); // Exponential backoff - - appLogger.d( - 'WatchTogether: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s', - ); - - _reconnectTimer?.cancel(); - _reconnectTimer = Timer(delay, () { - _peer?.reconnect(); - }); - } else { - appLogger.e('WatchTogether: Max reconnect attempts reached'); - _errorController.add( - const PeerError( - type: PeerErrorType.connectionFailed, - message: 'Lost connection to server after multiple reconnect attempts', - ), - ); - } - } - - /// Start peer health monitoring - void _startPeerHealthCheck() { - _peerHealthCheckTimer?.cancel(); - _peerHealthCheckTimer = Timer.periodic(_peerHealthCheckInterval, (_) { - _checkPeerHealth(); - }); - } - - /// Stop peer health monitoring - void _stopPeerHealthCheck() { - _peerHealthCheckTimer?.cancel(); - _peerHealthCheckTimer = null; - } - - /// Check health of all peer connections - void _checkPeerHealth() { - final now = DateTime.now(); - final peersToReconnect = []; - - for (final peerId in _connections.keys.toList()) { - final lastActivity = _lastPeerActivity[peerId]; - if (lastActivity != null && now.difference(lastActivity) > _peerTimeout) { - appLogger.w('WatchTogether: Peer $peerId timed out (no activity for ${_peerTimeout.inSeconds}s)'); - peersToReconnect.add(peerId); - } - } - - for (final peerId in peersToReconnect) { - _attemptPeerReconnect(peerId); - } - } - - /// Update peer activity timestamp (called on each message received) - void _updatePeerActivity(String peerId) { - _lastPeerActivity[peerId] = DateTime.now(); - // Reset reconnect attempts on successful activity - _peerReconnectAttempts[peerId] = 0; - } - - /// Attempt to reconnect to a peer with exponential backoff - void _attemptPeerReconnect(String peerId) { - final attempts = _peerReconnectAttempts[peerId] ?? 0; - - if (attempts >= _maxPeerReconnectAttempts) { - appLogger.e('WatchTogether: Max reconnect attempts reached for peer $peerId'); - // Remove the dead connection and notify - _connections.remove(peerId); - _lastPeerActivity.remove(peerId); - _peerReconnectAttempts.remove(peerId); - _peerDisconnectedController.add(peerId); - - if (_connections.isEmpty) { - _connectionStateController.add(false); - } - return; - } - - _peerReconnectAttempts[peerId] = attempts + 1; - final delay = Duration(seconds: (attempts + 1) * 2); // Exponential backoff - - appLogger.d( - 'WatchTogether: Attempting peer reconnect to $peerId (${attempts + 1}/$_maxPeerReconnectAttempts) in ${delay.inSeconds}s', - ); - - // Close existing connection if any - _connections[peerId]?.close(); - _connections.remove(peerId); - - // Schedule reconnection attempt - Timer(delay, () { - if (_peer != null && !_connections.containsKey(peerId)) { - appLogger.d('WatchTogether: Reconnecting to peer $peerId'); - final conn = _peer!.connect(peerId, options: PeerConnectOption(reliable: true)); - _handleNewConnection(conn, isOutgoing: true); - } - }); } /// Broadcast a message to all connected peers void broadcast(SyncMessage message) { - final json = message.toJson(); - appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connections.length} peers'); - - for (final conn in _connections.values) { - try { - conn.send(json); - } catch (e) { - appLogger.e('WatchTogether: Failed to send to ${conn.peer}', error: e); - } - } + final payload = message.toJson(); + appLogger.d('WatchTogether: Broadcasting ${message.type} to ${_connectedPeers.length} peers'); + _sendRaw({'type': 'broadcast', 'payload': payload}); } /// Send a message to a specific peer void sendTo(String peerId, SyncMessage message) { - final conn = _connections[peerId]; - if (conn != null) { - try { - conn.send(message.toJson()); - } catch (e) { - appLogger.e('WatchTogether: Failed to send to $peerId', error: e); - } - } else { - appLogger.w('WatchTogether: No connection to peer: $peerId'); - } + final payload = message.toJson(); + appLogger.d('WatchTogether: Sending ${message.type} to $peerId'); + _sendRaw({'type': 'sendTo', 'to': peerId, 'payload': payload}); } /// Disconnect from all peers and close the session @@ -416,22 +406,15 @@ class WatchTogetherPeerService { _reconnectTimer?.cancel(); _reconnectTimer = null; + _stopTimers(); - // Stop peer health monitoring - _stopPeerHealthCheck(); - _lastPeerActivity.clear(); - _peerReconnectAttempts.clear(); + _channelSubscription?.cancel(); + _channelSubscription = null; - // Close all data connections - for (final conn in _connections.values) { - conn.close(); - } - _connections.clear(); - - // Destroy the peer - _peer?.dispose(); - _peer = null; + await _channel?.sink.close(); + _channel = null; + _connectedPeers.clear(); _sessionId = null; _myPeerId = null; _isHost = false; diff --git a/lib/watch_together/services/watch_together_sync_manager.dart b/lib/watch_together/services/watch_together_sync_manager.dart index 37e8247c..b203c943 100644 --- a/lib/watch_together/services/watch_together_sync_manager.dart +++ b/lib/watch_together/services/watch_together_sync_manager.dart @@ -144,14 +144,22 @@ class WatchTogetherSyncManager { void initializeParticipants(List peerIds) { for (final peerId in peerIds) { if (peerId != _peerService.myPeerId) { - // Assume they're buffering until they tell us otherwise - _participantBuffering[peerId] = true; - // They're not ready until they send playerReady - _participantReady[peerId] = false; + if (_session.isHost) { + // Host waits for each peer to load their video before allowing play. + _participantBuffering[peerId] = true; + _participantReady[peerId] = false; + } else { + // Guests use optimistic defaults — the host coordinates readiness + // and will broadcast pause/play as needed. Pessimistic defaults + // cause a deadlock because the host's playerReady/buffering + // messages arrive before the sync manager subscribes. + _participantBuffering[peerId] = false; + _participantReady[peerId] = true; + } } } final otherCount = peerIds.where((id) => id != _peerService.myPeerId).length; - appLogger.d('WatchTogether: Initialized $otherCount existing participants'); + appLogger.d('WatchTogether: Initialized $otherCount existing participants (host=${_session.isHost})'); } /// Detach the player and stop sync @@ -637,10 +645,16 @@ class WatchTogetherSyncManager { void _handlePeerJoin(SyncMessage message) { appLogger.d('WatchTogether: Peer joined: ${message.displayName}'); - // Assume new peer is buffering and not ready until they explicitly signal if (message.peerId != null) { - _participantBuffering[message.peerId!] = true; - _participantReady[message.peerId!] = false; + if (_session.isHost) { + // Host waits for new peer to load their video before allowing play. + _participantBuffering[message.peerId!] = true; + _participantReady[message.peerId!] = false; + } else if (!_participantBuffering.containsKey(message.peerId!)) { + // Guests use optimistic defaults for peers they haven't seen yet. + _participantBuffering[message.peerId!] = false; + _participantReady[message.peerId!] = true; + } } // If we're the host, send session config AND our own join info to the new peer @@ -648,6 +662,16 @@ class WatchTogetherSyncManager { // Only send config if our video is loaded (we know the correct position) if (_hasAnnouncedReady) { _sendSessionConfig(toPeerId: message.peerId); + + // Re-send our ready and buffering state so the new peer doesn't + // get stuck waiting for updates that were broadcast before it joined. + _peerService.sendTo(message.peerId!, SyncMessage.playerReady(peerId: _peerService.myPeerId!, ready: true)); + } + if (_player != null) { + _peerService.sendTo( + message.peerId!, + SyncMessage.buffering(_player!.state.buffering, peerId: _peerService.myPeerId), + ); } // Send host's join info so guest adds host to their participants list _peerService.sendTo( @@ -663,6 +687,15 @@ class WatchTogetherSyncManager { appLogger.d('WatchTogether: Received session config'); + // The host only sends sessionConfig after its player is ready, so we + // can safely mark it as ready and not buffering. This prevents the + // guest from being permanently stuck waiting for ready/buffering + // messages that were broadcast before it joined. + if (message.peerId != null) { + _participantReady[message.peerId!] = true; + _participantBuffering[message.peerId!] = false; + } + // Update control mode if (message.controlMode != null) { onSessionConfigReceived?.call(message.controlMode!); diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 69e7208d..0a25aa35 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -15,9 +14,6 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin"); - flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar); g_autoptr(FlPluginRegistrar) os_media_controls_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OsMediaControlsPlugin"); os_media_controls_plugin_register_with_registrar(os_media_controls_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a560d685..eb699977 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - flutter_webrtc os_media_controls screen_retriever_linux sqlite3_flutter_libs diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 5e16df69..bc322b32 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,7 +8,6 @@ import Foundation import connectivity_plus import device_info_plus import file_picker -import flutter_webrtc import in_app_review import os_media_controls import package_info_plus @@ -26,7 +25,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 2db07d38..d1a1c2fb 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,9 +5,6 @@ PODS: - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - - flutter_webrtc (1.2.0): - - FlutterMacOS - - WebRTC-SDK (= 137.7151.04) - FlutterMacOS (1.0.0) - in_app_review (2.0.0): - FlutterMacOS @@ -57,7 +54,6 @@ PODS: - FlutterMacOS - wakelock_plus (0.0.1): - FlutterMacOS - - WebRTC-SDK (137.7151.04) - window_manager (0.2.0): - FlutterMacOS @@ -65,7 +61,6 @@ DEPENDENCIES: - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`) - os_media_controls (from `Flutter/ephemeral/.symlinks/plugins/os_media_controls/macos`) @@ -83,7 +78,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - sqlite3 - - WebRTC-SDK EXTERNAL SOURCES: connectivity_plus: @@ -92,8 +86,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos - flutter_webrtc: - :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos FlutterMacOS: :path: Flutter/ephemeral in_app_review: @@ -125,7 +117,6 @@ SPEC CHECKSUMS: connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a - flutter_webrtc: 718eae22a371cd94e5d56aa4f301443ebc5bb737 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 in_app_review: 66e7680752b632d83f4f0e88b34d52ed303fbff4 os_media_controls: c07c04c4afdf59dda0a3f398457a46823c4ce0ed @@ -139,7 +130,6 @@ SPEC CHECKSUMS: universal_gamepad: 8922f1f238f62d6847de887228976d5b572b57da url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b - WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c PODFILE CHECKSUM: d16f5e5d196d1ca9863d7a71d5885cad7ffa7d2d diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 1ac8c870..c02af024 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 0BAE8FC95BA6BEE8BE54E226 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C288E870F896AD55006C19AF /* Pods_RunnerTests.framework */; }; 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; @@ -32,8 +33,7 @@ 6AD8B1622ED7B50000E9E1B4 /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */; }; 6AD8B1662ED7B50000E9E1B5 /* WindowUtilsPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */; }; 6AD8B1672ED7B50000E9E1B5 /* WindowDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */; }; - 9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */; }; - FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */; }; + DF7527C52F58F90C45939A79 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -67,7 +67,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 21731DD0518FC0A0720FA340 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 0914125761A3FFED904A420B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 0BF4BB2D93DB27E23E7E5693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -83,20 +84,19 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 6AC86ED62EA70B4C0067BC66 /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = ""; }; 6AD8B1632ED7B50000E9E1B4 /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = ""; }; 6AD8B1642ED7B50000E9E1B4 /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = ""; }; 6AD8B1682ED7B50000E9E1B5 /* WindowUtilsPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtilsPlugin.swift; sourceTree = ""; }; 6AD8B1692ED7B50000E9E1B5 /* WindowDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDelegate.swift; sourceTree = ""; }; - 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6E770EFFB2EDFFA0C566F238 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 8EA2FC77E7FECB9A7FA97A34 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 9B9AC1D995B9CED225415BEA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + AE89312A44808FBB8B2C16DA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + C288E870F896AD55006C19AF /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FD163DB087582522BD55DCAB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -104,7 +104,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FE52EE1D2D489FA88507D14E /* Pods_RunnerTests.framework in Frameworks */, + 0BAE8FC95BA6BEE8BE54E226 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,8 +112,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9AECA605E2E1BECE3CA5BD01 /* Pods_Runner.framework in Frameworks */, 6AB6B0952EDB27C100EAC8DB /* MPVKit-GPL in Frameworks */, + DF7527C52F58F90C45939A79 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -123,12 +123,12 @@ 295400F6A94D53E806ED64A8 /* Pods */ = { isa = PBXGroup; children = ( - 21731DD0518FC0A0720FA340 /* Pods-Runner.debug.xcconfig */, - 4E4BFEE5F31FF1CC308F1693 /* Pods-Runner.release.xcconfig */, - 8EA2FC77E7FECB9A7FA97A34 /* Pods-Runner.profile.xcconfig */, - B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */, - B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */, - 5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */, + 6E770EFFB2EDFFA0C566F238 /* Pods-Runner.debug.xcconfig */, + 0BF4BB2D93DB27E23E7E5693 /* Pods-Runner.release.xcconfig */, + 9B9AC1D995B9CED225415BEA /* Pods-Runner.profile.xcconfig */, + 0914125761A3FFED904A420B /* Pods-RunnerTests.debug.xcconfig */, + AE89312A44808FBB8B2C16DA /* Pods-RunnerTests.release.xcconfig */, + FD163DB087582522BD55DCAB /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -161,7 +161,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, 295400F6A94D53E806ED64A8 /* Pods */, - 36FC208C02D36438C31F4842 /* Frameworks */, + A25A237F70C8052CAABD850E /* Frameworks */, ); sourceTree = ""; }; @@ -211,15 +211,6 @@ path = Runner; sourceTree = ""; }; - 36FC208C02D36438C31F4842 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 78F9F23331B540A25C4A70C7 /* Pods_Runner.framework */, - 84260356652A3270E664FED4 /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; 6AD8B1652ED7B50000E9E1B4 /* MpvPlayer */ = { isa = PBXGroup; children = ( @@ -229,6 +220,15 @@ path = MpvPlayer; sourceTree = ""; }; + A25A237F70C8052CAABD850E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 81BACC65DEDF97C1CA1F025F /* Pods_Runner.framework */, + C288E870F896AD55006C19AF /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -236,7 +236,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 2969483E1AB2CCAF8910A74F /* [CP] Check Pods Manifest.lock */, + CEDCA9B4C8CE113636A31DED /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -255,13 +255,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - B3C3284135875B6B88373CF1 /* [CP] Check Pods Manifest.lock */, + 835BFBC4F69B434783C6907B /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 246382E10D2DDCBEF8E02166 /* [CP] Embed Pods Frameworks */, + 53E8C05D7685362D47C19320 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -347,45 +347,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 246382E10D2DDCBEF8E02166 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 2969483E1AB2CCAF8910A74F /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -424,7 +385,24 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - B3C3284135875B6B88373CF1 /* [CP] Check Pods Manifest.lock */ = { + 53E8C05D7685362D47C19320 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 835BFBC4F69B434783C6907B /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -446,6 +424,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + CEDCA9B4C8CE113636A31DED /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -501,7 +501,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B733A8329CDDCE38392B9494 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 0914125761A3FFED904A420B /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -516,7 +516,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B69F8D562E2B39315A1369D6 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = AE89312A44808FBB8B2C16DA /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -531,7 +531,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5EBEC2B546108DF45BEB0B00 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = FD163DB087582522BD55DCAB /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/pubspec.lock b/pubspec.lock index f5c95c5a..7465774c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -297,14 +297,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" - dart_webrtc: - dependency: transitive - description: - name: dart_webrtc - sha256: "51bcda4ba5d7dd9e65a309244ce3ac0b58025e6e1f6d7442cee4cd02134ef65f" - url: "https://pub.dev" - source: hosted - version: "1.6.0" dbus: dependency: transitive description: @@ -369,14 +361,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.3" - events_emitter: - dependency: transitive - description: - name: events_emitter - sha256: a075477bdf9c8c0c31bb7c7b7bdd357b4486c34f30163119f96de4e7f54abeff - url: "https://pub.dev" - source: hosted - version: "0.5.2" fake_async: dependency: transitive description: @@ -485,14 +469,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_webrtc: - dependency: "direct overridden" - description: - name: flutter_webrtc - sha256: "71a38363a5b50603e405c275f30de2eb90f980b0cc94b0e1e9d8b9d6a6b03bf0" - url: "https://pub.dev" - source: hosted - version: "1.2.1" frontend_server_client: dependency: transitive description: @@ -814,14 +790,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - peerdart: - dependency: "direct main" - description: - name: peerdart - sha256: "1d0db041d42194f42e57d8889d029fb8d107610bf1062b39f43f4651de547e3c" - url: "https://pub.dev" - source: hosted - version: "0.5.6" petitparser: dependency: transitive description: @@ -1396,21 +1364,13 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 url: "https://pub.dev" source: hosted version: "3.0.3" - webrtc_interface: - dependency: transitive - description: - name: webrtc_interface - sha256: "2e604a31703ad26781782fb14fa8a4ee621154ee2c513d2b9938e486fa695233" - url: "https://pub.dev" - source: hosted - version: "1.3.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 19b6d7e5..460ab4b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: saf_util: ^0.11.0 saf_stream: ^0.12.2 material_symbols_icons: ^4.2892.0 - peerdart: ^0.5.6 + web_socket_channel: ^3.0.1 in_app_review: ^2.0.11 dart_discord_presence: ^1.1.0 @@ -59,9 +59,6 @@ dev_dependencies: dart_code_linter: ^3.1.1 drift_dev: ^2.14.0 -dependency_overrides: - flutter_webrtc: ^1.2.1 - flutter: uses-material-design: true assets: diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 00000000..561f8375 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,13 @@ +module github.com/edde746/plezy-relay + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.31.0 +) + +require ( + golang.org/x/net v0.33.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 00000000..60532402 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,8 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= diff --git a/server/main.go b/server/main.go new file mode 100644 index 00000000..cf3f94df --- /dev/null +++ b/server/main.go @@ -0,0 +1,388 @@ +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/acme/autocert" +) + +const ( + maxRoomSize = 8 + rateBurst = 30 + rateSustained = 10 + cleanupInterval = 5 * time.Minute + emptyRoomMaxAge = 5 * time.Minute + roomMaxAge = 24 * time.Hour + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingInterval = 30 * time.Second + maxMessageSize = 64 * 1024 +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// --- Rate limiter (token bucket) --- + +type rateLimiter struct { + tokens float64 + maxTokens float64 + refillRate float64 + lastTime time.Time + mu sync.Mutex +} + +func newRateLimiter(burst, sustained int) *rateLimiter { + return &rateLimiter{ + tokens: float64(burst), + maxTokens: float64(burst), + refillRate: float64(sustained), + lastTime: time.Now(), + } +} + +func (rl *rateLimiter) allow() bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + elapsed := now.Sub(rl.lastTime).Seconds() + rl.lastTime = now + + rl.tokens += elapsed * rl.refillRate + if rl.tokens > rl.maxTokens { + rl.tokens = rl.maxTokens + } + + if rl.tokens < 1 { + return false + } + rl.tokens-- + return true +} + +// --- Messages --- + +type clientMsg struct { + Type string `json:"type"` + SessionID string `json:"sessionId,omitempty"` + PeerID string `json:"peerId,omitempty"` + To string `json:"to,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +type serverMsg struct { + Type string `json:"type"` + SessionID string `json:"sessionId,omitempty"` + PeerID string `json:"peerId,omitempty"` + From string `json:"from,omitempty"` + Peers []string `json:"peers,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` +} + +// --- Room --- + +type Room struct { + SessionID string + HostPeerID string + Peers map[string]*websocket.Conn + mu sync.RWMutex + CreatedAt time.Time +} + +func (r *Room) peerIDs() []string { + ids := make([]string, 0, len(r.Peers)) + for id := range r.Peers { + ids = append(ids, id) + } + return ids +} + +func (r *Room) broadcastExcept(senderID string, msg serverMsg) { + data, err := json.Marshal(msg) + if err != nil { + return + } + r.mu.RLock() + defer r.mu.RUnlock() + for id, conn := range r.Peers { + if id != senderID { + conn.SetWriteDeadline(time.Now().Add(writeWait)) + conn.WriteMessage(websocket.TextMessage, data) + } + } +} + +func (r *Room) sendTo(targetID string, msg serverMsg) bool { + data, err := json.Marshal(msg) + if err != nil { + return false + } + r.mu.RLock() + defer r.mu.RUnlock() + conn, ok := r.Peers[targetID] + if !ok { + return false + } + conn.SetWriteDeadline(time.Now().Add(writeWait)) + conn.WriteMessage(websocket.TextMessage, data) + return true +} + +// --- Server --- + +type Server struct { + rooms map[string]*Room + mu sync.RWMutex +} + +func newServer() *Server { + s := &Server{rooms: make(map[string]*Room)} + go s.cleanupLoop() + return s +} + +func (s *Server) cleanupLoop() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + for range ticker.C { + s.mu.Lock() + now := time.Now() + for id, room := range s.rooms { + room.mu.RLock() + empty := len(room.Peers) == 0 + age := now.Sub(room.CreatedAt) + room.mu.RUnlock() + + if (empty && age > emptyRoomMaxAge) || age > roomMaxAge { + log.Printf("cleanup: removing room %s (empty=%v, age=%v)", id, empty, age) + delete(s.rooms, id) + } + } + s.mu.Unlock() + } +} + +func (s *Server) sendError(conn *websocket.Conn, code, message string) { + data, _ := json.Marshal(serverMsg{Type: "error", Code: code, Message: message}) + conn.SetWriteDeadline(time.Now().Add(writeWait)) + conn.WriteMessage(websocket.TextMessage, data) +} + +func (s *Server) sendJSON(conn *websocket.Conn, msg serverMsg) { + data, _ := json.Marshal(msg) + conn.SetWriteDeadline(time.Now().Add(writeWait)) + conn.WriteMessage(websocket.TextMessage, data) +} + +func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("upgrade error: %v", err) + return + } + defer conn.Close() + + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + // Ping ticker + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + go func() { + for range ticker.C { + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + }() + + rl := newRateLimiter(rateBurst, rateSustained) + var currentRoom *Room + var currentPeerID string + + // Cleanup on disconnect + defer func() { + if currentRoom != nil && currentPeerID != "" { + currentRoom.mu.Lock() + delete(currentRoom.Peers, currentPeerID) + currentRoom.mu.Unlock() + currentRoom.broadcastExcept(currentPeerID, serverMsg{ + Type: "peerLeft", + PeerID: currentPeerID, + }) + log.Printf("peer %s left room %s", currentPeerID, currentRoom.SessionID) + } + }() + + for { + _, raw, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("read error: %v", err) + } + return + } + + if !rl.allow() { + s.sendError(conn, "rate_limited", "Too many messages") + continue + } + + var msg clientMsg + if err := json.Unmarshal(raw, &msg); err != nil { + s.sendError(conn, "invalid_message", "Invalid JSON") + continue + } + + switch msg.Type { + case "create": + if msg.SessionID == "" || msg.PeerID == "" { + s.sendError(conn, "invalid_message", "sessionId and peerId required") + continue + } + s.mu.Lock() + if _, exists := s.rooms[msg.SessionID]; exists { + s.mu.Unlock() + s.sendError(conn, "room_exists", "Room already exists") + continue + } + room := &Room{ + SessionID: msg.SessionID, + HostPeerID: msg.PeerID, + Peers: map[string]*websocket.Conn{msg.PeerID: conn}, + CreatedAt: time.Now(), + } + s.rooms[msg.SessionID] = room + s.mu.Unlock() + currentRoom = room + currentPeerID = msg.PeerID + log.Printf("room %s created by %s", msg.SessionID, msg.PeerID) + s.sendJSON(conn, serverMsg{Type: "created", SessionID: msg.SessionID}) + + case "join": + if msg.SessionID == "" || msg.PeerID == "" { + s.sendError(conn, "invalid_message", "sessionId and peerId required") + continue + } + s.mu.RLock() + room, exists := s.rooms[msg.SessionID] + s.mu.RUnlock() + if !exists { + s.sendError(conn, "room_not_found", "Room does not exist") + continue + } + room.mu.Lock() + if len(room.Peers) >= maxRoomSize { + room.mu.Unlock() + s.sendError(conn, "room_full", "Room is full") + continue + } + room.Peers[msg.PeerID] = conn + peers := room.peerIDs() + room.mu.Unlock() + currentRoom = room + currentPeerID = msg.PeerID + log.Printf("peer %s joined room %s", msg.PeerID, msg.SessionID) + + // Tell the joiner who's already here (excluding themselves) + existingPeers := make([]string, 0, len(peers)-1) + for _, p := range peers { + if p != msg.PeerID { + existingPeers = append(existingPeers, p) + } + } + s.sendJSON(conn, serverMsg{Type: "joined", SessionID: msg.SessionID, Peers: existingPeers}) + room.broadcastExcept(msg.PeerID, serverMsg{Type: "peerJoined", PeerID: msg.PeerID}) + + case "broadcast": + if currentRoom == nil { + s.sendError(conn, "not_in_room", "Not in a room") + continue + } + currentRoom.broadcastExcept(currentPeerID, serverMsg{ + Type: "message", + From: currentPeerID, + Payload: msg.Payload, + }) + + case "sendTo": + if currentRoom == nil { + s.sendError(conn, "not_in_room", "Not in a room") + continue + } + if msg.To == "" { + s.sendError(conn, "invalid_message", "to field required") + continue + } + if !currentRoom.sendTo(msg.To, serverMsg{ + Type: "message", + From: currentPeerID, + Payload: msg.Payload, + }) { + s.sendError(conn, "not_in_room", "Target peer not found") + } + + case "ping": + s.sendJSON(conn, serverMsg{Type: "pong"}) + + default: + s.sendError(conn, "invalid_message", "Unknown message type") + } + } +} + +func main() { + dev := flag.Bool("dev", false, "Run in development mode (plain HTTP on :8080)") + host := flag.String("host", "ice.plezy.app", "Hostname for TLS autocert") + flag.Parse() + + srv := newServer() + + mux := http.NewServeMux() + mux.HandleFunc("/relay", srv.handleWS) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + if *dev { + log.Println("Starting dev server on :8080") + log.Fatal(http.ListenAndServe(":8080", mux)) + } else { + certManager := autocert.Manager{ + Prompt: autocert.AcceptTOS, + HostPolicy: autocert.HostWhitelist(*host), + Cache: autocert.DirCache("/var/lib/plezy-relay/certs"), + } + + server := &http.Server{ + Addr: ":443", + Handler: mux, + TLSConfig: certManager.TLSConfig(), + } + + // HTTP challenge server for Let's Encrypt + go func() { + log.Println("Starting HTTP challenge server on :80") + log.Fatal(http.ListenAndServe(":80", certManager.HTTPHandler(nil))) + }() + + log.Printf("Starting relay server on :443 (host=%s)", *host) + log.Fatal(server.ListenAndServeTLS("", "")) + } +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 88e1c798..bc3ebff2 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,7 +7,6 @@ #include "generated_plugin_registrant.h" #include -#include #include #include #include @@ -18,8 +17,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { ConnectivityPlusWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); - FlutterWebRTCPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); OsMediaControlsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("OsMediaControlsPluginCApi")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 1dca6fac..db77f229 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,7 +4,6 @@ list(APPEND FLUTTER_PLUGIN_LIST connectivity_plus - flutter_webrtc os_media_controls screen_retriever_windows sqlite3_flutter_libs