| // Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:convert'; |
| import 'dart:typed_data'; |
| |
| import 'package:objective_c/objective_c.dart' as objc; |
| import 'package:web_socket/web_socket.dart'; |
| |
| import 'cupertino_api.dart'; |
| |
| /// An error occurred while connecting to the peer. |
| class ConnectionException extends WebSocketException { |
| final objc.NSError error; |
| |
| ConnectionException(super.message, this.error); |
| |
| @override |
| String toString() => 'CupertinoErrorWebSocketException: $message $error'; |
| } |
| |
| /// A [WebSocket] implemented using the |
| /// [NSURLSessionWebSocketTask API](https://developer.apple.com/documentation/foundation/nsurlsessionwebsockettask). |
| /// |
| /// > [!NOTE] |
| /// > The [WebSocket] interface is currently experimental and may change in the |
| /// > future. |
| /// |
| /// ```dart |
| /// import 'package:cupertino_http/cupertino_http.dart'; |
| /// import 'package:web_socket/web_socket.dart'; |
| /// |
| /// void main() async { |
| /// final socket = await CupertinoWebSocket.connect( |
| /// Uri.parse('wss://ws.postman-echo.com/raw')); |
| /// |
| /// socket.events.listen((e) async { |
| /// switch (e) { |
| /// case TextDataReceived(text: final text): |
| /// print('Received Text: $text'); |
| /// await socket.close(); |
| /// case BinaryDataReceived(data: final data): |
| /// print('Received Binary: $data'); |
| /// case CloseReceived(code: final code, reason: final reason): |
| /// print('Connection to server closed: $code [$reason]'); |
| /// } |
| /// }); |
| /// } |
| /// ``` |
| /// |
| /// > [!TIP] |
| /// > [`AdapterWebSocketChannel`](https://pub.dev/documentation/web_socket_channel/latest/adapter_web_socket_channel/AdapterWebSocketChannel-class.html) |
| /// > can be used to adapt a [CupertinoWebSocket] into a |
| /// > [`WebSocketChannel`](https://pub.dev/documentation/web_socket_channel/latest/web_socket_channel/WebSocketChannel-class.html). |
| class CupertinoWebSocket implements WebSocket { |
| /// Create a new WebSocket connection using the |
| /// [NSURLSessionWebSocketTask API](https://developer.apple.com/documentation/foundation/nsurlsessionwebsockettask). |
| /// |
| /// The URL supplied in [url] must use the scheme ws or wss. |
| /// |
| /// If provided, the [protocols] argument indicates that subprotocols that |
| /// the peer is able to select. See |
| /// [RFC-6455 1.9](https://datatracker.ietf.org/doc/html/rfc6455#section-1.9). |
| /// |
| /// NOTE: the [WebSocket] interface is currently experimental and may change |
| /// in the future. |
| static Future<CupertinoWebSocket> connect(Uri url, |
| {Iterable<String>? protocols, URLSessionConfiguration? config}) async { |
| if (!url.isScheme('ws') && !url.isScheme('wss')) { |
| throw ArgumentError.value( |
| url, 'url', 'only ws: and wss: schemes are supported'); |
| } |
| |
| final readyCompleter = Completer<CupertinoWebSocket>(); |
| late CupertinoWebSocket webSocket; |
| |
| final session = URLSession.sessionWithConfiguration( |
| config ?? URLSessionConfiguration.defaultSessionConfiguration(), |
| // In a successful flow, the callbacks will be made in this order: |
| // onWebSocketTaskOpened(...) // Good connect. |
| // <receive/send messages to the peer> |
| // onWebSocketTaskClosed(...) // Optional: peer sent Close frame. |
| // onComplete(..., error=null) // Disconnected. |
| // |
| // In a failure to connect to the peer, the flow will be: |
| // onComplete(session, task, error=error): |
| // |
| // `onComplete` can also be called at any point if the peer is |
| // disconnected without Close frames being exchanged. |
| onWebSocketTaskOpened: (session, task, protocol) { |
| webSocket = CupertinoWebSocket._(task, protocol ?? ''); |
| readyCompleter.complete(webSocket); |
| }, onWebSocketTaskClosed: (session, task, closeCode, reason) { |
| assert(readyCompleter.isCompleted); |
| webSocket._connectionClosed(closeCode, reason); |
| }, onComplete: (session, task, error) { |
| if (!readyCompleter.isCompleted) { |
| // `onWebSocketTaskOpened should have been called and completed |
| // `readyCompleter`. So either there was a error creating the connection |
| // or a logic error. |
| if (error == null) { |
| throw AssertionError( |
| 'expected an error or "onWebSocketTaskOpened" to be called ' |
| 'first'); |
| } |
| readyCompleter.completeError( |
| ConnectionException('connection ended unexpectedly', error)); |
| } else { |
| // There are three possibilities here: |
| // 1. the peer sent a close Frame, `onWebSocketTaskClosed` was already |
| // called and `_connectionClosed` is a no-op. |
| // 2. we sent a close Frame (through `close()`) and `_connectionClosed` |
| // is a no-op. |
| // 3. an error occurred (e.g. network failure) and `_connectionClosed` |
| // will signal that and close `event`. |
| webSocket._connectionClosed( |
| 1006, 'abnormal close'.codeUnits.toNSData()); |
| } |
| }); |
| |
| session.webSocketTaskWithURL(url, protocols: protocols).resume(); |
| return readyCompleter.future; |
| } |
| |
| final URLSessionWebSocketTask _task; |
| final String _protocol; |
| final _events = StreamController<WebSocketEvent>(); |
| |
| CupertinoWebSocket._(this._task, this._protocol) { |
| _scheduleReceive(); |
| } |
| |
| /// Handle an incoming message from the peer and schedule receiving the next |
| /// message. |
| void _handleMessage(URLSessionWebSocketMessage value) { |
| if (_events.isClosed) return; |
| |
| late WebSocketEvent event; |
| switch (value.type) { |
| case NSURLSessionWebSocketMessageType |
| .NSURLSessionWebSocketMessageTypeString: |
| event = TextDataReceived(value.string!); |
| break; |
| case NSURLSessionWebSocketMessageType |
| .NSURLSessionWebSocketMessageTypeData: |
| event = BinaryDataReceived(value.data!.toList()); |
| break; |
| } |
| _events.add(event); |
| _scheduleReceive(); |
| } |
| |
| void _scheduleReceive() { |
| unawaited(_task |
| .receiveMessage() |
| .then(_handleMessage, onError: _closeConnectionWithError)); |
| } |
| |
| /// Close the WebSocket connection due to an error and send the |
| /// [CloseReceived] event. |
| void _closeConnectionWithError(Object e) { |
| if (e is objc.NSError) { |
| if (e.domain.toString() == 'NSPOSIXErrorDomain' && e.code == 57) { |
| // Socket is not connected. |
| // onWebSocketTaskClosed/onComplete will be invoked and may indicate a |
| // close code. |
| return; |
| } |
| var (int code, String? reason) = switch ([e.domain.toString(), e.code]) { |
| ['NSPOSIXErrorDomain', 100] => ( |
| 1002, |
| e.localizedDescription.toString() |
| ), |
| _ => (1006, e.localizedDescription.toString()) |
| }; |
| _task.cancel(); |
| _connectionClosed(code, reason.codeUnits.toNSData()); |
| } else { |
| throw StateError('unexpected error: $e'); |
| } |
| } |
| |
| void _connectionClosed(int? closeCode, objc.NSData? reason) { |
| if (!_events.isClosed) { |
| final closeReason = reason == null ? '' : utf8.decode(reason.toList()); |
| |
| _events |
| ..add(CloseReceived(closeCode, closeReason)) |
| ..close(); |
| } |
| } |
| |
| @override |
| void sendBytes(Uint8List b) { |
| if (_events.isClosed) { |
| throw WebSocketConnectionClosed(); |
| } |
| _task |
| .sendMessage(URLSessionWebSocketMessage.fromData(b.toNSData())) |
| .then((value) => value, onError: _closeConnectionWithError); |
| } |
| |
| @override |
| void sendText(String s) { |
| if (_events.isClosed) { |
| throw WebSocketConnectionClosed(); |
| } |
| _task |
| .sendMessage(URLSessionWebSocketMessage.fromString(s)) |
| .then((value) => value, onError: _closeConnectionWithError); |
| } |
| |
| @override |
| Future<void> close([int? code, String? reason]) async { |
| if (_events.isClosed) { |
| throw WebSocketConnectionClosed(); |
| } |
| |
| if (code != null && code != 1000 && !(code >= 3000 && code <= 4999)) { |
| throw ArgumentError('Invalid argument: $code, close code must be 1000 or ' |
| 'in the range 3000-4999'); |
| } |
| if (reason != null && utf8.encode(reason).length > 123) { |
| throw ArgumentError.value(reason, 'reason', |
| 'reason must be <= 123 bytes long when encoded as UTF-8'); |
| } |
| |
| if (!_events.isClosed) { |
| unawaited(_events.close()); |
| if (code != null) { |
| reason = reason ?? ''; |
| _task.cancelWithCloseCode(code, utf8.encode(reason).toNSData()); |
| } else { |
| _task.cancel(); |
| } |
| } |
| } |
| |
| @override |
| Stream<WebSocketEvent> get events => _events.stream; |
| |
| @override |
| String get protocol => _protocol; |
| } |