Mostly working websocket stuff, some message weirdness at the moment...
This commit is contained in:
parent
623474e0c6
commit
b37862a321
backend
frontend/lib
features/room
providers
shared_models/lib
@ -1,5 +1,5 @@
|
|||||||
import 'package:backend/database.dart';
|
import 'package:backend/db/database.dart';
|
||||||
import 'package:backend/service/db_access.dart';
|
import 'package:backend/db/db_access.dart';
|
||||||
import 'package:backend/utils/environment.dart';
|
import 'package:backend/utils/environment.dart';
|
||||||
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@ -25,7 +25,13 @@ class Authenticator {
|
|||||||
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||||
final jwt = JWT(
|
final jwt = JWT(
|
||||||
header: {'algo': 'HS256'},
|
header: {'algo': 'HS256'},
|
||||||
JWTBody(uuid: newUser.uuid, roomUuid: newUser.gameRoomUuid, iat: iat, exp: iat + expTimeSecs).toJson(),
|
JWTBody(
|
||||||
|
uuid: newUser.uuid,
|
||||||
|
roomUuid: newUser.gameRoomUuid,
|
||||||
|
roomCode: req.roomCode,
|
||||||
|
iat: iat,
|
||||||
|
exp: iat + expTimeSecs,
|
||||||
|
).toJson(),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (jwt.sign(SecretKey(jwtSecret!)), newUser);
|
return (jwt.sign(SecretKey(jwtSecret!)), newUser);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import 'package:backend/database.dart';
|
import 'package:backend/db/database.dart';
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@ -9,11 +9,12 @@ class Db {
|
|||||||
static final _db = AppDatabase();
|
static final _db = AppDatabase();
|
||||||
|
|
||||||
static Future<User?> getUserById(String uuid) {
|
static Future<User?> getUserById(String uuid) {
|
||||||
log.finer('Getting user $uuid');
|
log.finest('Getting user $uuid');
|
||||||
return _db.managers.users.filter((f) => f.uuid.equals(uuid)).getSingleOrNull();
|
return _db.managers.users.filter((f) => f.uuid.equals(uuid)).getSingleOrNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<User?> createUser({required String username, required String roomCode}) async {
|
static Future<User?> createUser({required String username, required String roomCode}) async {
|
||||||
|
log.finest('Creating user $username in room $roomCode');
|
||||||
final room = await _db.managers.gameRooms
|
final room = await _db.managers.gameRooms
|
||||||
.filter((f) => f.code.equals(roomCode) & f.status.isIn([GameStatus.open, GameStatus.running]))
|
.filter((f) => f.code.equals(roomCode) & f.status.isIn([GameStatus.open, GameStatus.running]))
|
||||||
.getSingleOrNull()
|
.getSingleOrNull()
|
||||||
@ -32,21 +33,30 @@ class Db {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<GameRoom?> createRoom({required String roomCode}) => _db.managers.gameRooms
|
static Future<GameRoom?> createRoom({required String roomCode}) {
|
||||||
.createReturningOrNull(
|
log.finest('Creating room with code $roomCode');
|
||||||
(o) => o(createdAt: Value(DateTime.now()), status: GameStatus.open, uuid: const Uuid().v4(), code: roomCode),
|
return _db.managers.gameRooms
|
||||||
)
|
.createReturningOrNull(
|
||||||
.catchError(
|
(o) => o(createdAt: Value(DateTime.now()), status: GameStatus.open, uuid: const Uuid().v4(), code: roomCode),
|
||||||
(Object err) {
|
)
|
||||||
log.severe('Failed to create room', err, StackTrace.current);
|
.catchError(
|
||||||
return null;
|
(Object err) {
|
||||||
},
|
log.severe('Failed to create room', err, StackTrace.current);
|
||||||
);
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<GameRoom?> getRoomByCode(String? roomCode) async {
|
static Future<GameRoom?> getRoomByCode(String? roomCode) async {
|
||||||
|
log.finest('Getting room by code $roomCode');
|
||||||
final room = await _db.managers.gameRooms
|
final room = await _db.managers.gameRooms
|
||||||
.filter((f) => f.code.equals(roomCode) & f.status.isIn([GameStatus.open, GameStatus.running]))
|
.filter((f) => f.code.equals(roomCode) & f.status.isIn([GameStatus.open, GameStatus.running]))
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<GameRoom?> getRoomById(String roomUuid) async {
|
||||||
|
log.finest('Getting room $roomUuid');
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
}
|
}
|
121
backend/lib/game_room_manager.dart
Normal file
121
backend/lib/game_room_manager.dart
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:shared_models/room.dart';
|
||||||
|
|
||||||
|
// class GameRoomManager {
|
||||||
|
// GameRoomManager({required SendPort this.socketManagerSendPort}) {
|
||||||
|
// managerReceivePort = ReceivePort();
|
||||||
|
// gamePorts = {};
|
||||||
|
// receiveSubscription = managerReceivePort.listen((message) {
|
||||||
|
// if (message is GameRoomManagerMessage) {
|
||||||
|
// handleMessage(message);
|
||||||
|
// } else if (message is GameRoomMessage) {
|
||||||
|
// final gameUuid = message.gameUuid;
|
||||||
|
// final gamePort = gamePorts[gameUuid];
|
||||||
|
// if (gamePort == null) {
|
||||||
|
// _logger.warning('Received GameRoomMessage for empty gamePort');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// gamePort.send(message);
|
||||||
|
// } else {
|
||||||
|
// _logger.warning('Received unknown message: $message');
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// late final Map<String, SendPort> gamePorts;
|
||||||
|
// late final ReceivePort managerReceivePort;
|
||||||
|
// late final StreamSubscription<dynamic> receiveSubscription;
|
||||||
|
// final SendPort socketManagerSendPort;
|
||||||
|
// final Logger _logger = Logger('GameRoomManager');
|
||||||
|
|
||||||
|
// void close() {
|
||||||
|
// receiveSubscription.cancel();
|
||||||
|
// //TODO remove connections
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Future<void> createRoom({required String roomUuid}) async {
|
||||||
|
// // receivePort
|
||||||
|
// final wsSendPort = SocketManager().createWsSendPort(roomUuid);
|
||||||
|
// await Isolate.spawn(LiveGameRoom.spawn, LiveGameRoomData(roomUuid: roomUuid, wsSendPort: wsSendPort, gameManagerSendPort: ));
|
||||||
|
// // first message from new isolate will be its SendPort
|
||||||
|
// gamePorts[roomUuid] = await roomReceivePort.first as SendPort;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// void routePlayerToRoom(String roomCode, PlayerConnection player) {
|
||||||
|
// gamePorts[roomCode]?.addPlayer(player);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// void handleMessage(message) {
|
||||||
|
// throw UnimplementedError();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
class LiveGameRoomData {
|
||||||
|
LiveGameRoomData({
|
||||||
|
required this.wsSendPort,
|
||||||
|
required this.roomUuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SendPort wsSendPort;
|
||||||
|
final String roomUuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveGameRoom {
|
||||||
|
LiveGameRoom({
|
||||||
|
required this.receivePort,
|
||||||
|
required this.wsSendPort,
|
||||||
|
required this.logger,
|
||||||
|
required this.streamSubscription,
|
||||||
|
required this.roomUuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
Timer? gameLoop;
|
||||||
|
// final Map<String, PlayerState> players = {};
|
||||||
|
final ReceivePort receivePort;
|
||||||
|
final SendPort wsSendPort;
|
||||||
|
final Logger logger;
|
||||||
|
final StreamSubscription<dynamic> streamSubscription;
|
||||||
|
final String roomUuid;
|
||||||
|
|
||||||
|
static void spawn(LiveGameRoomData data) {
|
||||||
|
// Create new isolate for this room
|
||||||
|
// Return handle for communication
|
||||||
|
final receivePort = ReceivePort();
|
||||||
|
data.wsSendPort.send(receivePort.sendPort);
|
||||||
|
final logger = Logger('LiveGameRoom-${data.roomUuid}');
|
||||||
|
|
||||||
|
// ignore: cancel_subscriptions
|
||||||
|
final streamSubscription = receivePort.listen(logger.info);
|
||||||
|
|
||||||
|
LiveGameRoom(
|
||||||
|
receivePort: receivePort,
|
||||||
|
wsSendPort: data.wsSendPort,
|
||||||
|
logger: logger,
|
||||||
|
streamSubscription: streamSubscription,
|
||||||
|
roomUuid: data.roomUuid,
|
||||||
|
).start();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
gameLoop = Timer.periodic(const Duration(milliseconds: 750), update);
|
||||||
|
}
|
||||||
|
|
||||||
|
void update(Timer timer) {
|
||||||
|
logger.finest('Room $roomUuid tick: ${timer.tick}');
|
||||||
|
wsSendPort.send(
|
||||||
|
RoomPingMessage(
|
||||||
|
roomUuid,
|
||||||
|
dest: PingDestination.client,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
streamSubscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
@ -24,8 +24,13 @@ Middleware tokenAuthMiddleware({
|
|||||||
// use `auth.verifyToken(token)` to check the jwt that came in the request header bearer
|
// use `auth.verifyToken(token)` to check the jwt that came in the request header bearer
|
||||||
final authHeader = context.request.headers['authorization'] ?? context.request.headers['Authorization'];
|
final authHeader = context.request.headers['authorization'] ?? context.request.headers['Authorization'];
|
||||||
final auths = authHeader?.split(' ');
|
final auths = authHeader?.split(' ');
|
||||||
if (authHeader == null || !authHeader.startsWith('Bearer ') || auths == null || auths.length != 2) {
|
if (authHeader == null ||
|
||||||
log.fine('Denied request - No Auth - ${context.request.method.value} ${context.request.uri.path}');
|
!authHeader.toLowerCase().startsWith('bearer') ||
|
||||||
|
auths == null ||
|
||||||
|
auths.length != 2) {
|
||||||
|
log.fine(
|
||||||
|
'Denied request, no Auth - ${context.request.method.value} ${context.request.uri.path}, Found $auths',
|
||||||
|
);
|
||||||
return Response(statusCode: HttpStatus.unauthorized);
|
return Response(statusCode: HttpStatus.unauthorized);
|
||||||
}
|
}
|
||||||
final token = auths.last;
|
final token = auths.last;
|
||||||
|
138
backend/lib/socket_manager.dart
Normal file
138
backend/lib/socket_manager.dart
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:backend/game_room_manager.dart';
|
||||||
|
import 'package:dart_frog_web_socket/dart_frog_web_socket.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:shared_models/room.dart';
|
||||||
|
|
||||||
|
final _logger = Logger('SocketManager');
|
||||||
|
|
||||||
|
class SocketManager {
|
||||||
|
// Default constructor returns instance
|
||||||
|
factory SocketManager() {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private constructor
|
||||||
|
SocketManager._();
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
static final SocketManager instance = SocketManager._();
|
||||||
|
|
||||||
|
// Store connections: GameRoomUuid -> UserUuid -> WebSocketChannel
|
||||||
|
final _connections = <String, Map<String, WebSocketChannel>>{};
|
||||||
|
// Store isolate port and stream subscription to said port
|
||||||
|
final _gameRoomSendPorts = <String, SendPort>{};
|
||||||
|
final _gameRoomSpSubs = <String, StreamSubscription<dynamic>>{};
|
||||||
|
|
||||||
|
// Add a new connection
|
||||||
|
Future<Status> addConnection(
|
||||||
|
WebSocketChannel connection, {
|
||||||
|
required String roomUuid,
|
||||||
|
required String userUuid,
|
||||||
|
}) async {
|
||||||
|
_logger.finer('Adding connection to socket manager for user $userUuid in room $roomUuid');
|
||||||
|
if (!_gameRoomSpSubs.containsKey(roomUuid)) {
|
||||||
|
final status = await spawnGameRoomIsolate(roomUuid);
|
||||||
|
if (status == Status.failure) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_connections.putIfAbsent(roomUuid, () => <String, WebSocketChannel>{});
|
||||||
|
_connections[roomUuid]![userUuid] = connection;
|
||||||
|
return Status.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a connection
|
||||||
|
void removeConnection(String roomUuid, String userUuid) {
|
||||||
|
_connections[roomUuid]?.remove(userUuid);
|
||||||
|
if (_connections[roomUuid]?.isEmpty ?? false) {
|
||||||
|
_connections.remove(roomUuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a specific user's connection
|
||||||
|
WebSocketChannel? getConnection(String roomUuid, String userUuid) {
|
||||||
|
return _connections[roomUuid]?[userUuid];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all connections in a room
|
||||||
|
Map<String, WebSocketChannel>? getRoomConnections(String roomUuid) {
|
||||||
|
return _connections[roomUuid];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast message to all users in a room except sender
|
||||||
|
void broadcastToRoom(String roomUuid, GameRoomMessage message, {String? excludeUserUuid}) {
|
||||||
|
_logger.fine('Broadcasting ${message.type} to room $roomUuid');
|
||||||
|
_connections[roomUuid]?.forEach((userUuid, connection) {
|
||||||
|
if (userUuid != excludeUserUuid) {
|
||||||
|
connection.sink.add(jsonEncode(message.toJson()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a user is connected
|
||||||
|
bool isConnected(String roomUuid, String userUuid) {
|
||||||
|
return _connections[roomUuid]?.containsKey(userUuid) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Status> spawnGameRoomIsolate(String roomUuid) async {
|
||||||
|
try {
|
||||||
|
_logger.finest('Spawning isolate for room $roomUuid');
|
||||||
|
if (_gameRoomSendPorts.containsKey(roomUuid)) {
|
||||||
|
_logger.severe('Tried to create a sendPort for an existing room uuid: $roomUuid', null, StackTrace.current);
|
||||||
|
throw Exception('Cannot create sendPort, room already has one');
|
||||||
|
}
|
||||||
|
final receivePort = ReceivePort();
|
||||||
|
final sp = receivePort.sendPort;
|
||||||
|
await Isolate.spawn(LiveGameRoom.spawn, LiveGameRoomData(roomUuid: roomUuid, wsSendPort: sp));
|
||||||
|
// used to get sendport
|
||||||
|
final completer = Completer<SendPort>();
|
||||||
|
|
||||||
|
// ignore: cancel_subscriptions
|
||||||
|
final sub = receivePort.listen((message) {
|
||||||
|
// first message from new isolate will be its SendPort
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(message as SendPort);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message is GameRoomMessage) {
|
||||||
|
switch (message) {
|
||||||
|
case RoomPingMessage():
|
||||||
|
if (message.dest != PingDestination.client) {
|
||||||
|
_logger.warning('Got room ping meant for server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
broadcastToRoom(
|
||||||
|
message.roomUuid,
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
|
||||||
|
case PlayerVoteMessage():
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case PingMessage():
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_logger.info('Unknown message: $message');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_gameRoomSpSubs[roomUuid] = sub;
|
||||||
|
_gameRoomSendPorts[roomUuid] = await completer.future;
|
||||||
|
_logger.info('Spawned new game room $roomUuid, listening to messages from room.');
|
||||||
|
return Status.success;
|
||||||
|
} catch (e) {
|
||||||
|
_logger.severe('Failed to spawn game room isolate', e, StackTrace.current);
|
||||||
|
_gameRoomSendPorts.remove(roomUuid);
|
||||||
|
final sub = _gameRoomSpSubs.remove(roomUuid);
|
||||||
|
await sub?.cancel();
|
||||||
|
}
|
||||||
|
return Status.failure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Status { success, failure }
|
@ -1,7 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:backend/service/db_access.dart';
|
import 'package:backend/db/db_access.dart';
|
||||||
import 'package:dart_frog/dart_frog.dart';
|
import 'package:dart_frog/dart_frog.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:shared_models/room.dart';
|
import 'package:shared_models/room.dart';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import 'package:backend/service/db_access.dart';
|
import 'package:backend/db/db_access.dart';
|
||||||
import 'package:dart_frog/dart_frog.dart';
|
import 'package:dart_frog/dart_frog.dart';
|
||||||
|
|
||||||
Future<Response> onRequest(RequestContext context, String roomCode) async {
|
Future<Response> onRequest(RequestContext context, String roomCode) async {
|
||||||
|
34
backend/routes/room/[roomCode]/ws.dart
Normal file
34
backend/routes/room/[roomCode]/ws.dart
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import 'package:backend/db/database.dart';
|
||||||
|
import 'package:backend/db/db_access.dart';
|
||||||
|
import 'package:backend/socket_manager.dart';
|
||||||
|
import 'package:dart_frog/dart_frog.dart';
|
||||||
|
import 'package:dart_frog_web_socket/dart_frog_web_socket.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
Future<Response> onRequest(RequestContext context, String roomCode) async {
|
||||||
|
final logger = Logger('room/[$roomCode]/ws');
|
||||||
|
|
||||||
|
final handler = webSocketHandler(protocols: ['game.room.v1'], (channel, protocol) async {
|
||||||
|
try {
|
||||||
|
channel.sink.add('test');
|
||||||
|
logger.finest(protocol);
|
||||||
|
final room = await Db.getRoomByCode(roomCode);
|
||||||
|
if (room == null) {
|
||||||
|
logger.finer('Room not found, aborting...');
|
||||||
|
await channel.sink.close(4404, 'Room not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final user = context.read<User>();
|
||||||
|
|
||||||
|
final status = await SocketManager().addConnection(channel, roomUuid: room.uuid, userUuid: user.uuid);
|
||||||
|
if (status == Status.failure) {
|
||||||
|
logger.finer('Failed to spawn room isolate, closing connection.');
|
||||||
|
await channel.sink.close(4404, 'Room not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.severe('Unexpected error occurred getting websocket connection', e, StackTrace.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return handler(context);
|
||||||
|
}
|
@ -1,9 +0,0 @@
|
|||||||
import 'package:dart_frog/dart_frog.dart';
|
|
||||||
import 'package:dart_frog_web_socket/dart_frog_web_socket.dart';
|
|
||||||
|
|
||||||
Future<Response> onRequest(RequestContext context) async {
|
|
||||||
final handler = webSocketHandler((channel, protocol) {
|
|
||||||
channel.stream.listen(print);
|
|
||||||
});
|
|
||||||
return handler(context);
|
|
||||||
}
|
|
@ -1,6 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:backend/service/db_access.dart';
|
import 'package:backend/db/db_access.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_models/room.dart';
|
import 'package:shared_models/room.dart';
|
||||||
import 'package:shared_models/user.dart';
|
import 'package:shared_models/user.dart';
|
||||||
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:frontend/providers/auth.dart';
|
import 'package:frontend/providers/auth.dart';
|
||||||
import 'package:frontend/providers/game_messages.dart';
|
import 'package:frontend/providers/game_messages.dart';
|
||||||
|
import 'package:frontend/providers/web_socket.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
@ -17,6 +18,12 @@ class GameRoomHome extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GameRoomHomeState extends ConsumerState<GameRoomHome> {
|
class _GameRoomHomeState extends ConsumerState<GameRoomHome> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => ref.read(webSocketNotifierProvider.notifier).connect());
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final jwt = ref.watch(jwtBodyProvider);
|
final jwt = ref.watch(jwtBodyProvider);
|
||||||
@ -25,13 +32,30 @@ class _GameRoomHomeState extends ConsumerState<GameRoomHome> {
|
|||||||
// return home
|
// return home
|
||||||
context.go('/');
|
context.go('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final connection = ref.watch(webSocketNotifierProvider).valueOrNull;
|
||||||
|
|
||||||
|
ref.listen(
|
||||||
|
gameMessageNotifierProvider,
|
||||||
|
(previous, next) {
|
||||||
|
print('Got message: $next');
|
||||||
|
},
|
||||||
|
);
|
||||||
// enstablish ws connection at /room/roomCode/ws and save to gameMessageProvider
|
// enstablish ws connection at /room/roomCode/ws and save to gameMessageProvider
|
||||||
ref.read(gameMessageNotifierProvider.notifier).connect(jwt!.roomUuid);
|
return Scaffold(
|
||||||
return Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Text('Authenticated.'),
|
Text('Authenticated.'),
|
||||||
Text('Welcome to room ${widget.roomUuid}'),
|
Text('Welcome to room ${widget.roomUuid}'),
|
||||||
],
|
ElevatedButton(
|
||||||
|
onPressed: connection == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
connection.add('Test message');
|
||||||
|
},
|
||||||
|
child: Text('Send message on socket')),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,16 +30,14 @@ class _JoinRoomHomeState extends ConsumerState<JoinRoomHome> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final jwtAsync = ref.watch(jwtNotifierProvider);
|
final jwtBody = ref.watch(jwtBodyProvider);
|
||||||
|
|
||||||
jwtAsync.whenData((jwt) {
|
if (jwtBody != null) {
|
||||||
logger.fine('Got jwt: ${jwt == null ? 'NULL' : jwt.toString().substring(10)}');
|
|
||||||
if (jwt == null) return;
|
|
||||||
logger.fine('Navigating to game room screen');
|
logger.fine('Navigating to game room screen');
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
(_) => context.go('/room/${jwt.roomUuid}'),
|
(_) => context.go('/room/${jwtBody.roomUuid}'),
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Padding(
|
body: Padding(
|
||||||
|
@ -40,6 +40,11 @@ class JwtNotifier extends _$JwtNotifier {
|
|||||||
|
|
||||||
state = AsyncValue.data(jwt);
|
state = AsyncValue.data(jwt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> eraseJwt() async {
|
||||||
|
SharedPreferencesAsync().remove('jwt');
|
||||||
|
state = AsyncValue.data(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
@ -52,8 +57,7 @@ JWTBody? jwtBody(Ref ref) {
|
|||||||
final payload = JwtDecoder.tryDecode(jwtString);
|
final payload = JwtDecoder.tryDecode(jwtString);
|
||||||
if (payload == null) {
|
if (payload == null) {
|
||||||
logger.fine('Failed to decode JWT, removing key.');
|
logger.fine('Failed to decode JWT, removing key.');
|
||||||
SharedPreferencesAsync().remove('jwt');
|
ref.read(jwtNotifierProvider.notifier).eraseJwt();
|
||||||
ref.invalidate(jwtNotifierProvider);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:frontend/providers/auth.dart';
|
import 'package:frontend/providers/auth.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:shared_models/jwt.dart';
|
||||||
|
|
||||||
part 'dio.g.dart';
|
part 'dio.g.dart';
|
||||||
|
|
||||||
@ -17,8 +18,13 @@ Dio dio(Ref ref) {
|
|||||||
));
|
));
|
||||||
|
|
||||||
final jwt = ref.watch(jwtNotifierProvider).valueOrNull;
|
final jwt = ref.watch(jwtNotifierProvider).valueOrNull;
|
||||||
|
final jwtBody = ref.read(jwtBodyProvider);
|
||||||
|
|
||||||
dio.interceptors.addAll([JwtInterceptor(jwt: jwt), CustomLogInterceptor()]);
|
dio.interceptors.addAll([
|
||||||
|
JwtInterceptor(
|
||||||
|
jwt: jwt, jwtBody: jwtBody, invalidateJwtCallback: () => ref.read(jwtNotifierProvider.notifier).eraseJwt()),
|
||||||
|
CustomLogInterceptor()
|
||||||
|
]);
|
||||||
|
|
||||||
logger.fine('Created new Dio object');
|
logger.fine('Created new Dio object');
|
||||||
|
|
||||||
@ -28,15 +34,36 @@ Dio dio(Ref ref) {
|
|||||||
// Adds the jwt to
|
// Adds the jwt to
|
||||||
class JwtInterceptor extends Interceptor {
|
class JwtInterceptor extends Interceptor {
|
||||||
final String? jwt;
|
final String? jwt;
|
||||||
|
final JWTBody? jwtBody;
|
||||||
|
final Function invalidateJwtCallback;
|
||||||
|
|
||||||
JwtInterceptor({required this.jwt});
|
JwtInterceptor({required this.jwt, required this.jwtBody, required this.invalidateJwtCallback});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||||
|
if (jwt == null || jwtBody == null) {
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (jwtBody != null && jwtBody!.exp < DateTime.now().millisecondsSinceEpoch ~/ 1000) {
|
||||||
|
invalidateJwtCallback();
|
||||||
|
handler.next(options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
options.headers['Authorization'] = 'Bearer $jwt';
|
options.headers['Authorization'] = 'Bearer $jwt';
|
||||||
|
handler.next(options);
|
||||||
}
|
}
|
||||||
handler.next(options);
|
}
|
||||||
|
|
||||||
|
// on unauthorized request, remove jwt
|
||||||
|
@override
|
||||||
|
// ignore: strict_raw_type
|
||||||
|
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
invalidateJwtCallback();
|
||||||
|
}
|
||||||
|
handler.next(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,43 +1,47 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:frontend/providers/web_socket.dart';
|
||||||
import 'package:frontend/providers/dio.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:shared_models/room.dart';
|
import 'package:shared_models/room.dart';
|
||||||
|
|
||||||
part 'game_messages.g.dart';
|
part 'game_messages.g.dart';
|
||||||
|
|
||||||
|
final _logger = Logger('GameMessageNotifier');
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
class GameMessageNotifier extends _$GameMessageNotifier {
|
class GameMessageNotifier extends _$GameMessageNotifier {
|
||||||
|
StreamSubscription<GameRoomMessage?>? _sub;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<GameRoomMessage> build() {
|
Stream<GameRoomMessage?> build() {
|
||||||
return Stream.empty();
|
final Stream<dynamic>? stream = ref.watch(webSocketStreamProvider);
|
||||||
}
|
if (stream == null) {
|
||||||
|
return Stream.empty();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> connect(String gameRoomUuid) async {
|
final Stream<GameRoomMessage?> gameRoomStream = stream.map((message) {
|
||||||
final dio = ref.read(dioProvider);
|
|
||||||
Uri.parse('ws://localhost:8080/room/$gameRoomUuid/ws');
|
|
||||||
|
|
||||||
// connect to websocket and then set stream of websocket to state
|
|
||||||
final wsUrl = Uri.parse('ws://localhost:8080/room/$gameRoomUuid/ws');
|
|
||||||
|
|
||||||
final connection = await WebSocket.connect(wsUrl.toString());
|
|
||||||
|
|
||||||
final Stream<GameRoomMessage> gameRoomStream = connection.map((message) {
|
|
||||||
try {
|
try {
|
||||||
if (message is String) {
|
if (message is String) {
|
||||||
GameRoomMessage.fromJson(jsonDecode(message) as Map<String, dynamic>);
|
return GameRoomMessage.fromJson(jsonDecode(message) as Map<String, dynamic>);
|
||||||
} else {
|
} else {
|
||||||
logger.info('Recieved non-string message in socket: $message');
|
_logger.info('Recieved non-string message in socket: $message');
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error parsing message: $e');
|
_logger.severe('Error parsing message: `${message.runtimeType}` $message', e, StackTrace.current);
|
||||||
rethrow;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
gameRoomStream.listen(
|
_sub = gameRoomStream.listen(
|
||||||
(event) => state = AsyncValue.data(event),
|
(event) => state = AsyncValue.data(event),
|
||||||
);
|
);
|
||||||
|
return gameRoomStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel the gameroom stream subscription
|
||||||
|
void close() {
|
||||||
|
_sub?.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
63
frontend/lib/providers/web_socket.dart
Normal file
63
frontend/lib/providers/web_socket.dart
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:frontend/providers/auth.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'web_socket.g.dart';
|
||||||
|
|
||||||
|
final _logger = Logger('WebSocketNotifier');
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class WebSocketNotifier extends _$WebSocketNotifier {
|
||||||
|
@override
|
||||||
|
Future<WebSocket?> build() async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect() async {
|
||||||
|
state = AsyncValue.loading();
|
||||||
|
final jwt = ref.read(jwtNotifierProvider).valueOrNull;
|
||||||
|
final jwtBody = ref.read(jwtBodyProvider);
|
||||||
|
if (jwt == null || jwtBody == null) {
|
||||||
|
_logger.warning('Tried to connect to ws without jwt token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final wsUrl = Uri.parse('ws://localhost:8080/room/${jwtBody.roomCode}/ws');
|
||||||
|
_logger.finest('Attempting to connect to $wsUrl');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final connection = await WebSocket.connect(wsUrl.toString(), headers: {'Authorization': 'Bearer: $jwt'});
|
||||||
|
_logger.fine('Client ws connection established to room ${jwtBody.roomUuid}');
|
||||||
|
state = AsyncValue.data(connection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e is WebSocketException) {
|
||||||
|
if (e.httpStatusCode == 401) {
|
||||||
|
ref.read(jwtNotifierProvider.notifier).eraseJwt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_logger.warning('Error occurred creating web socket: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// @riverpod
|
||||||
|
// class WebSocketStreamNotifier extends _$WebSocketStreamNotifier {
|
||||||
|
// @override
|
||||||
|
// Stream<dynamic> build() {
|
||||||
|
// final connection = ref.watch(webSocketNotifierProvider).valueOrNull;
|
||||||
|
// if (connection == null) return Stream.empty();
|
||||||
|
// _logger.finest('Created broadcast stream from ws connection');
|
||||||
|
// return connection.asBroadcastStream();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Raw<Stream<dynamic>> webSocketStream(Ref ref) {
|
||||||
|
final connection = ref.watch(webSocketNotifierProvider).valueOrNull;
|
||||||
|
if (connection == null) return Stream.empty();
|
||||||
|
_logger.finest('Created broadcast stream from ws connection');
|
||||||
|
return connection.asBroadcastStream();
|
||||||
|
}
|
@ -6,10 +6,13 @@ part 'jwt.g.dart';
|
|||||||
class JWTBody {
|
class JWTBody {
|
||||||
String uuid;
|
String uuid;
|
||||||
String roomUuid;
|
String roomUuid;
|
||||||
|
String roomCode;
|
||||||
|
// Issued at in epoch seconds
|
||||||
int iat;
|
int iat;
|
||||||
|
// Expires at in epoch seconds
|
||||||
int exp;
|
int exp;
|
||||||
|
|
||||||
JWTBody({required this.uuid, required this.roomUuid, required this.iat, required this.exp});
|
JWTBody({required this.uuid, required this.roomUuid, required this.roomCode, required this.iat, required this.exp});
|
||||||
|
|
||||||
factory JWTBody.fromJson(Map<String, dynamic> json) => _$JWTBodyFromJson(json);
|
factory JWTBody.fromJson(Map<String, dynamic> json) => _$JWTBodyFromJson(json);
|
||||||
|
|
||||||
|
@ -25,11 +25,14 @@ class CreateRoomResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sealed class GameRoomMessage {
|
sealed class GameRoomMessage {
|
||||||
const GameRoomMessage();
|
GameRoomMessage(this.roomUuid);
|
||||||
|
final String roomUuid;
|
||||||
|
abstract final String type;
|
||||||
|
|
||||||
factory GameRoomMessage.fromJson(Map<String, dynamic> json) {
|
factory GameRoomMessage.fromJson(Map<String, dynamic> json) {
|
||||||
return switch (json['type']) {
|
return switch (json['type']) {
|
||||||
'ping' => PingMessage.fromJson(json),
|
'ping' => PingMessage.fromJson(json),
|
||||||
|
'roomPing' => RoomPingMessage.fromJson(json),
|
||||||
'playerVote' => PlayerVoteMessage.fromJson(json),
|
'playerVote' => PlayerVoteMessage.fromJson(json),
|
||||||
_ => throw Exception('Unknown message type'),
|
_ => throw Exception('Unknown message type'),
|
||||||
};
|
};
|
||||||
@ -38,36 +41,61 @@ sealed class GameRoomMessage {
|
|||||||
Map<String, dynamic> toJson();
|
Map<String, dynamic> toJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PingDestination { client, server }
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
class PingMessage extends GameRoomMessage {
|
class PingMessage extends GameRoomMessage {
|
||||||
final DateTime timestamp;
|
DateTime timestamp;
|
||||||
|
final PingDestination dest;
|
||||||
|
final String userUuid;
|
||||||
|
|
||||||
const PingMessage({required this.timestamp});
|
PingMessage(super.roomUuid, {required this.dest, required this.userUuid}) {
|
||||||
|
timestamp = DateTime.now();
|
||||||
|
type = 'ping';
|
||||||
|
}
|
||||||
|
|
||||||
factory PingMessage.fromJson(Map<String, dynamic> json) =>
|
factory PingMessage.fromJson(Map<String, dynamic> json) => _$PingMessageFromJson(json);
|
||||||
PingMessage(timestamp: DateTime.parse(json['timestamp'] as String));
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => _$PingMessageToJson(this);
|
||||||
'type': 'ping',
|
|
||||||
'timestamp': timestamp.toIso8601String(),
|
@override
|
||||||
};
|
late final String type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
|
class RoomPingMessage extends GameRoomMessage {
|
||||||
|
late final DateTime timestamp;
|
||||||
|
final PingDestination dest;
|
||||||
|
|
||||||
|
RoomPingMessage(super.roomUuid, {required this.dest}) {
|
||||||
|
timestamp = DateTime.now();
|
||||||
|
type = 'roomPing';
|
||||||
|
}
|
||||||
|
|
||||||
|
factory RoomPingMessage.fromJson(Map<String, dynamic> json) => _$RoomPingMessageFromJson(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() => _$RoomPingMessageToJson(this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
late final String type;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
class PlayerVoteMessage extends GameRoomMessage {
|
class PlayerVoteMessage extends GameRoomMessage {
|
||||||
final String playerUuid;
|
final String playerUuid;
|
||||||
final int vote;
|
final int vote;
|
||||||
|
|
||||||
const PlayerVoteMessage({required this.playerUuid, required this.vote});
|
PlayerVoteMessage(super.roomUuid, {required this.playerUuid, required this.vote}) {
|
||||||
|
type = 'playerVote';
|
||||||
|
}
|
||||||
|
|
||||||
factory PlayerVoteMessage.fromJson(Map<String, dynamic> json) => PlayerVoteMessage(
|
factory PlayerVoteMessage.fromJson(Map<String, dynamic> json) => _$PlayerVoteMessageFromJson(json);
|
||||||
playerUuid: json['playerUuid'] as String,
|
|
||||||
vote: json['vote'] as int,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => _$PlayerVoteMessageToJson(this);
|
||||||
'type': 'playerVote',
|
|
||||||
'playerUuid': playerUuid,
|
@override
|
||||||
'vote': vote,
|
late final String type;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user