mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): support open pairing relays (#1939)
Signed-off-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu <85d1fb36f341a371cee9aa12765c2068f79ff1b85e7176b02e6c4a738850f4f0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1shglkdhngx3hrnhf4gf8vhpqdrmeludctechdvpwd3988zzs7ncq2cmtxu
parent
158259d953
commit
a0081944ed
@@ -57,10 +57,34 @@ class PairingState {
|
||||
);
|
||||
}
|
||||
|
||||
typedef PairingSocketFactory =
|
||||
PairingSocket Function({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
});
|
||||
|
||||
class PairingNotifier extends Notifier<PairingState> {
|
||||
final PairingSocketFactory _socketFactory;
|
||||
PairingSocket? _socket;
|
||||
Timer? _sessionTimeout;
|
||||
|
||||
PairingNotifier({PairingSocketFactory? socketFactory})
|
||||
: _socketFactory = socketFactory ?? _createPairingSocket;
|
||||
|
||||
static PairingSocket _createPairingSocket({
|
||||
required String wsUrl,
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) => PairingSocket(
|
||||
wsUrl: wsUrl,
|
||||
ephemeralPrivkey: ephemeralPrivkey,
|
||||
onMessage: onMessage,
|
||||
onDisconnected: onDisconnected,
|
||||
);
|
||||
|
||||
@override
|
||||
PairingState build() => const PairingState();
|
||||
|
||||
@@ -171,20 +195,21 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
);
|
||||
|
||||
// 4. Connect to relay with ephemeral keys.
|
||||
_socket = PairingSocket(
|
||||
final socket = _socketFactory(
|
||||
wsUrl: relayWsUrl,
|
||||
ephemeralPrivkey: _ephemeralPrivkey!,
|
||||
onMessage: _handleRelayMessage,
|
||||
onDisconnected: _handleDisconnected,
|
||||
);
|
||||
await _socket!.connect();
|
||||
_socket = socket;
|
||||
await socket.connect();
|
||||
|
||||
if (!_socket!.isConnected) {
|
||||
throw Exception('Failed to connect to pairing relay');
|
||||
if (!socket.isConnected) {
|
||||
throw StateError('Pairing socket did not reach the connected state');
|
||||
}
|
||||
|
||||
// 5. Subscribe for kind:24134 events tagged to our ephemeral pubkey.
|
||||
_socket!.subscribe('pair', 24134, _ephemeralPubkey!);
|
||||
socket.subscribe('pair', 24134, _ephemeralPubkey!);
|
||||
|
||||
// 6. Wait briefly for EOSE, then send offer.
|
||||
// (In practice, we send the offer immediately — the relay will buffer it.)
|
||||
@@ -244,11 +269,18 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
message.contains('Connection refused') ||
|
||||
message.contains('Network is unreachable') ||
|
||||
message.contains('No route to host') ||
|
||||
message.contains('Failed to connect') ||
|
||||
message.contains('Null check operator used on a null value')) {
|
||||
message.contains('Failed to connect')) {
|
||||
return 'Could not reach the pairing relay. Check your internet '
|
||||
'connection and VPN, then try again.';
|
||||
}
|
||||
if (error is PairingAuthException) {
|
||||
return 'The pairing relay rejected authentication. Try creating a new '
|
||||
'pairing code.';
|
||||
}
|
||||
if (error is StateError ||
|
||||
message.contains('Null check operator used on a null value')) {
|
||||
return 'Pairing stopped because of an internal error. Please try again.';
|
||||
}
|
||||
if (message.contains('HandshakeException') ||
|
||||
message.contains('CERTIFICATE_VERIFY_FAILED')) {
|
||||
return 'Secure connection failed. Check your network settings '
|
||||
|
||||
@@ -6,20 +6,36 @@ import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../shared/relay/nostr_models.dart';
|
||||
|
||||
const _desktopPairingAuthChallengeGrace = Duration(seconds: 3);
|
||||
const _pairingAuthOkTimeout = Duration(seconds: 8);
|
||||
|
||||
/// Ephemeral WebSocket connection for NIP-AB pairing.
|
||||
///
|
||||
/// Uses ephemeral keys for NIP-42 auth (not the stored user keys).
|
||||
/// Single-use — disposed after the pairing session completes.
|
||||
class PairingAuthException implements Exception {
|
||||
final String message;
|
||||
|
||||
const PairingAuthException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'PairingAuthException: $message';
|
||||
}
|
||||
|
||||
class PairingSocket {
|
||||
final String _wsUrl;
|
||||
final String _ephemeralPrivkey;
|
||||
final void Function(List<dynamic> message) _onMessage;
|
||||
final void Function(Object? error) _onDisconnected;
|
||||
final Duration _authChallengeTimeout;
|
||||
final Duration _authResponseTimeout;
|
||||
final WebSocketChannel Function(Uri uri) _channelFactory;
|
||||
|
||||
WebSocketChannel? _channel;
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
Completer<void>? _authCompleter;
|
||||
Timer? _authTimeout;
|
||||
Timer? _authChallengeTimer;
|
||||
Timer? _authResponseTimer;
|
||||
String? _pendingAuthEventId;
|
||||
bool _connected = false;
|
||||
|
||||
@@ -28,54 +44,52 @@ class PairingSocket {
|
||||
required String ephemeralPrivkey,
|
||||
required void Function(List<dynamic> message) onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
Duration authChallengeTimeout = _desktopPairingAuthChallengeGrace,
|
||||
Duration authResponseTimeout = _pairingAuthOkTimeout,
|
||||
WebSocketChannel Function(Uri uri) channelFactory =
|
||||
WebSocketChannel.connect,
|
||||
}) : _wsUrl = wsUrl,
|
||||
_ephemeralPrivkey = ephemeralPrivkey,
|
||||
_onMessage = onMessage,
|
||||
_onDisconnected = onDisconnected;
|
||||
_onDisconnected = onDisconnected,
|
||||
_authChallengeTimeout = authChallengeTimeout,
|
||||
_authResponseTimeout = authResponseTimeout,
|
||||
_channelFactory = channelFactory;
|
||||
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Connect and authenticate via NIP-42.
|
||||
/// Connect and answer a NIP-42 challenge when the relay requires one.
|
||||
Future<void> connect() async {
|
||||
try {
|
||||
_channel = WebSocketChannel.connect(Uri.parse(_wsUrl));
|
||||
await _channel!.ready;
|
||||
} catch (e) {
|
||||
_onDisconnected(e);
|
||||
return;
|
||||
}
|
||||
_channel = _channelFactory(Uri.parse(_wsUrl));
|
||||
await _channel!.ready;
|
||||
|
||||
_authCompleter = Completer<void>();
|
||||
|
||||
_subscription = _channel!.stream.listen(
|
||||
_handleRawMessage,
|
||||
onError: (Object error) {
|
||||
_failAuth(error);
|
||||
_onDisconnected(error);
|
||||
},
|
||||
onDone: () {
|
||||
_failAuth(null);
|
||||
_onDisconnected(null);
|
||||
},
|
||||
onError: _failAuth,
|
||||
onDone: () => _failAuth(null),
|
||||
);
|
||||
|
||||
// Wait for auth with 8s timeout.
|
||||
_authTimeout = Timer(const Duration(seconds: 8), () {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(
|
||||
TimeoutException('NIP-42 auth timed out'),
|
||||
);
|
||||
// Dedicated pairing relays may be open and send no NIP-42 challenge.
|
||||
_authChallengeTimer = Timer(_authChallengeTimeout, () {
|
||||
if (_pendingAuthEventId == null &&
|
||||
_authCompleter != null &&
|
||||
!_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await _authCompleter!.future;
|
||||
_authTimeout?.cancel();
|
||||
_connected = true;
|
||||
} catch (e) {
|
||||
_authTimeout?.cancel();
|
||||
} catch (error) {
|
||||
await disconnect();
|
||||
_onDisconnected(e);
|
||||
_onDisconnected(error);
|
||||
rethrow;
|
||||
} finally {
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +119,8 @@ class PairingSocket {
|
||||
_connected = false;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_authTimeout?.cancel();
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
final channel = _channel;
|
||||
_channel = null;
|
||||
if (channel != null) {
|
||||
@@ -118,12 +133,19 @@ class PairingSocket {
|
||||
_subscription?.cancel();
|
||||
_channel?.sink.close();
|
||||
_channel = null;
|
||||
_authTimeout?.cancel();
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
}
|
||||
|
||||
void _failAuth(Object? error) {
|
||||
final authError = error ?? Exception('Connection closed');
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.completeError(error ?? Exception('Connection closed'));
|
||||
_authCompleter!.completeError(authError);
|
||||
return;
|
||||
}
|
||||
if (_connected) {
|
||||
unawaited(disconnect());
|
||||
_onDisconnected(authError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +177,14 @@ class PairingSocket {
|
||||
if (data.length < 2) return;
|
||||
final challenge = data[1] as String;
|
||||
|
||||
_authChallengeTimer?.cancel();
|
||||
_authResponseTimer?.cancel();
|
||||
_authResponseTimer = Timer(_authResponseTimeout, () {
|
||||
_failAuth(
|
||||
const PairingAuthException('Relay did not confirm authentication'),
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
// Build NIP-42 auth event (kind:22242) with ephemeral keys.
|
||||
final tags = <List<String>>[
|
||||
@@ -184,6 +214,7 @@ class PairingSocket {
|
||||
|
||||
if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) {
|
||||
_pendingAuthEventId = null;
|
||||
_authResponseTimer?.cancel();
|
||||
if (accepted) {
|
||||
if (_authCompleter != null && !_authCompleter!.isCompleted) {
|
||||
_authCompleter!.complete();
|
||||
@@ -192,7 +223,7 @@ class PairingSocket {
|
||||
final message = data.length > 3
|
||||
? data[3] as String
|
||||
: 'Auth rejected by relay';
|
||||
_failAuth(Exception(message));
|
||||
_failAuth(PairingAuthException(message));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/features/pairing/pairing_provider.dart';
|
||||
import 'package:buzz/features/pairing/pairing_socket.dart';
|
||||
import 'package:buzz/shared/auth/auth.dart';
|
||||
|
||||
/// Tests for [PairingNotifier]'s legacy `buzz://` payload parsing and
|
||||
@@ -43,6 +44,36 @@ void main() {
|
||||
expect(state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'disconnect during connect does not null-dereference the socket',
|
||||
() async {
|
||||
final notifier = PairingNotifier(
|
||||
socketFactory:
|
||||
({
|
||||
required wsUrl,
|
||||
required ephemeralPrivkey,
|
||||
required onMessage,
|
||||
required void Function(Object? error) onDisconnected,
|
||||
}) => _DisconnectingSocket(disconnectCallback: onDisconnected),
|
||||
);
|
||||
container = ProviderContainer(
|
||||
overrides: [pairingProvider.overrideWith(() => notifier)],
|
||||
);
|
||||
const code =
|
||||
'nostrpair://62287897da61e3fa294b4570575f7db8bea147d6631150f2e4656714c645fb1e'
|
||||
'?secret=abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789'
|
||||
'&relay=wss%3A%2F%2Fpairing.buzz.xyz&v=1';
|
||||
|
||||
await container.read(pairingProvider.notifier).pair(code);
|
||||
|
||||
expect(container.read(pairingProvider).status, PairingStatus.error);
|
||||
expect(
|
||||
container.read(pairingProvider).errorMessage,
|
||||
contains('internal error'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('payload missing nsec errors before contacting relay', () async {
|
||||
container = createContainer();
|
||||
|
||||
@@ -192,3 +223,21 @@ class FakeAuthNotifier extends AsyncNotifier<AuthState>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DisconnectingSocket extends PairingSocket {
|
||||
final void Function(Object? error) disconnectCallback;
|
||||
|
||||
_DisconnectingSocket({required this.disconnectCallback})
|
||||
: super(
|
||||
wsUrl: 'ws://unused',
|
||||
ephemeralPrivkey:
|
||||
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506',
|
||||
onMessage: (_) {},
|
||||
onDisconnected: (_) {},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> connect() async {
|
||||
disconnectCallback(Exception('Connection closed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:buzz/features/pairing/pairing_socket.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
const _privateKey =
|
||||
'09b3065e3570a3a4054660dccd66e12774a99a904fdb0ca02dbc6c3136249506';
|
||||
|
||||
void main() {
|
||||
group('PairingSocket', () {
|
||||
test('connects when the pairing relay sends no AUTH challenge', () async {
|
||||
final server = await _TestRelay.start((_) {});
|
||||
addTearDown(server.close);
|
||||
final socket = _socket(
|
||||
server.url,
|
||||
authChallengeTimeout: const Duration(milliseconds: 30),
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await socket.connect();
|
||||
|
||||
expect(socket.isConnected, isTrue);
|
||||
});
|
||||
|
||||
test('answers an AUTH challenge and requires an accepted OK', () async {
|
||||
final authReceived = Completer<List<dynamic>>();
|
||||
final server = await _TestRelay.start((webSocket) async {
|
||||
webSocket.add(jsonEncode(['AUTH', 'challenge']));
|
||||
final auth =
|
||||
jsonDecode(await webSocket.first as String) as List<dynamic>;
|
||||
authReceived.complete(auth);
|
||||
final event = auth[1] as Map<String, dynamic>;
|
||||
webSocket.add(jsonEncode(['OK', event['id'], true, 'authenticated']));
|
||||
});
|
||||
addTearDown(server.close);
|
||||
final socket = _socket(server.url);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await socket.connect();
|
||||
|
||||
expect(socket.isConnected, isTrue);
|
||||
expect((await authReceived.future).first, 'AUTH');
|
||||
});
|
||||
|
||||
test('fails when the pairing relay rejects AUTH', () async {
|
||||
final server = await _TestRelay.start((webSocket) async {
|
||||
webSocket.add(jsonEncode(['AUTH', 'challenge']));
|
||||
final auth =
|
||||
jsonDecode(await webSocket.first as String) as List<dynamic>;
|
||||
final event = auth[1] as Map<String, dynamic>;
|
||||
webSocket.add(jsonEncode(['OK', event['id'], false, 'bad auth']));
|
||||
});
|
||||
addTearDown(server.close);
|
||||
var disconnectCount = 0;
|
||||
final socket = _socket(
|
||||
server.url,
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await expectLater(socket.connect(), throwsA(isA<PairingAuthException>()));
|
||||
|
||||
expect(socket.isConnected, isFalse);
|
||||
expect(disconnectCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'answers a challenge after the optional AUTH wait completes',
|
||||
() async {
|
||||
final authReceived = Completer<void>();
|
||||
final server = await _TestRelay.start((webSocket) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
webSocket.add(jsonEncode(['AUTH', 'late-challenge']));
|
||||
final auth =
|
||||
jsonDecode(await webSocket.first as String) as List<dynamic>;
|
||||
final event = auth[1] as Map<String, dynamic>;
|
||||
webSocket.add(jsonEncode(['OK', event['id'], true, 'authenticated']));
|
||||
authReceived.complete();
|
||||
});
|
||||
addTearDown(server.close);
|
||||
final socket = _socket(
|
||||
server.url,
|
||||
authChallengeTimeout: const Duration(milliseconds: 30),
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await socket.connect();
|
||||
await authReceived.future;
|
||||
|
||||
expect(socket.isConnected, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('fails when AUTH receives no OK response', () async {
|
||||
final server = await _TestRelay.start((webSocket) {
|
||||
webSocket.add(jsonEncode(['AUTH', 'challenge']));
|
||||
});
|
||||
addTearDown(server.close);
|
||||
final socket = _socket(
|
||||
server.url,
|
||||
authResponseTimeout: const Duration(milliseconds: 100),
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await expectLater(socket.connect(), throwsA(isA<PairingAuthException>()));
|
||||
|
||||
expect(socket.isConnected, isFalse);
|
||||
});
|
||||
|
||||
test('notifies once when a connected stream emits an error', () async {
|
||||
var disconnectCount = 0;
|
||||
final channel = _ControlledWebSocketChannel();
|
||||
final socket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => channel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await socket.connect();
|
||||
channel.emitError(Exception('stream failed'));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(disconnectCount, 1);
|
||||
});
|
||||
|
||||
test('notifies once when a connected stream closes', () async {
|
||||
var disconnectCount = 0;
|
||||
final channel = _ControlledWebSocketChannel();
|
||||
final socket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => channel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
addTearDown(socket.disconnect);
|
||||
|
||||
await socket.connect();
|
||||
await channel.closeStream();
|
||||
|
||||
expect(disconnectCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'does not notify when deliberately disconnected or disposed',
|
||||
() async {
|
||||
var disconnectCount = 0;
|
||||
final disconnectChannel = _ControlledWebSocketChannel();
|
||||
final disconnectingSocket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => disconnectChannel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
await disconnectingSocket.connect();
|
||||
|
||||
await disconnectingSocket.disconnect();
|
||||
|
||||
final disposeChannel = _ControlledWebSocketChannel();
|
||||
final disposingSocket = _socket(
|
||||
'ws://unused',
|
||||
onDisconnected: (_) => disconnectCount++,
|
||||
channelFactory: (_) => disposeChannel,
|
||||
authChallengeTimeout: Duration.zero,
|
||||
);
|
||||
await disposingSocket.connect();
|
||||
|
||||
disposingSocket.dispose();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(disconnectCount, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
PairingSocket _socket(
|
||||
String url, {
|
||||
Duration authChallengeTimeout = const Duration(milliseconds: 500),
|
||||
Duration authResponseTimeout = const Duration(seconds: 10),
|
||||
void Function(Object? error)? onDisconnected,
|
||||
WebSocketChannel Function(Uri uri)? channelFactory,
|
||||
}) => PairingSocket(
|
||||
wsUrl: url,
|
||||
ephemeralPrivkey: _privateKey,
|
||||
onMessage: (_) {},
|
||||
onDisconnected: onDisconnected ?? (_) {},
|
||||
authChallengeTimeout: authChallengeTimeout,
|
||||
authResponseTimeout: authResponseTimeout,
|
||||
channelFactory: channelFactory ?? WebSocketChannel.connect,
|
||||
);
|
||||
|
||||
class _ControlledWebSocketChannel implements WebSocketChannel {
|
||||
final StreamController<dynamic> _streamController = StreamController();
|
||||
final WebSocketSink _sink = _ControlledWebSocketSink();
|
||||
|
||||
void emitError(Object error) => _streamController.addError(error);
|
||||
|
||||
Future<void> closeStream() => _streamController.close();
|
||||
|
||||
@override
|
||||
Future<void> get ready => Future.value();
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _streamController.stream;
|
||||
|
||||
@override
|
||||
WebSocketSink get sink => _sink;
|
||||
|
||||
@override
|
||||
int? get closeCode => null;
|
||||
|
||||
@override
|
||||
String? get closeReason => null;
|
||||
|
||||
@override
|
||||
String? get protocol => null;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _ControlledWebSocketSink implements WebSocketSink {
|
||||
@override
|
||||
void add(dynamic event) {}
|
||||
|
||||
@override
|
||||
void addError(Object error, [StackTrace? stackTrace]) {}
|
||||
|
||||
@override
|
||||
Future<void> addStream(Stream<dynamic> stream) async {
|
||||
await stream.drain<void>();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close([int? closeCode, String? closeReason]) async {}
|
||||
|
||||
@override
|
||||
Future<void> get done => Future.value();
|
||||
}
|
||||
|
||||
class _TestRelay {
|
||||
final HttpServer _server;
|
||||
final List<WebSocket> _sockets = [];
|
||||
|
||||
_TestRelay._(this._server);
|
||||
|
||||
String get url => 'ws://${_server.address.host}:${_server.port}';
|
||||
|
||||
static Future<_TestRelay> start(
|
||||
FutureOr<void> Function(WebSocket socket) onConnected,
|
||||
) async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final relay = _TestRelay._(server);
|
||||
server.listen((request) async {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
relay._sockets.add(socket);
|
||||
await onConnected(socket);
|
||||
});
|
||||
return relay;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
for (final socket in _sockets) {
|
||||
await socket.close();
|
||||
}
|
||||
await _server.close(force: true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user