From cfbc191c3ec9d8fb3fb5957498a2548571780590 Mon Sep 17 00:00:00 2001 From: Brian Quinlan Date: Tue, 19 Mar 2024 14:48:15 -0700 Subject: [PATCH] Add a WebSocket implementation to package:cupertino_http (#1153) --- .github/workflows/cupertino.yml | 4 +- .../example/integration_test/main.dart | 2 + .../example/integration_test/utils_test.dart | 31 ++- .../web_socket_conformance_test.dart | 22 ++ pkgs/cupertino_http/example/pubspec.yaml | 2 + pkgs/cupertino_http/lib/cupertino_http.dart | 1 + .../cupertino_http/lib/src/cupertino_api.dart | 37 +++- .../lib/src/cupertino_web_socket.dart | 204 ++++++++++++++++++ pkgs/cupertino_http/lib/src/utils.dart | 13 +- pkgs/cupertino_http/pubspec.yaml | 10 +- 10 files changed, 313 insertions(+), 13 deletions(-) create mode 100644 pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart create mode 100644 pkgs/cupertino_http/lib/src/cupertino_web_socket.dart diff --git a/.github/workflows/cupertino.yml b/.github/workflows/cupertino.yml index f434431b44..1110e60e08 100644 --- a/.github/workflows/cupertino.yml +++ b/.github/workflows/cupertino.yml @@ -31,7 +31,7 @@ jobs: matrix: # Test on the minimum supported flutter version and the latest # version. - flutter-version: ["3.16.0", "any"] + flutter-version: ["3.19.0", "any"] steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 - uses: subosito/flutter-action@2783a3f08e1baf891508463f8c6653c258246225 @@ -61,4 +61,4 @@ jobs: run: | cd example flutter pub get - flutter test --timeout=1200s integration_test/ + flutter test --timeout=1200s integration_test/main.dart diff --git a/pkgs/cupertino_http/example/integration_test/main.dart b/pkgs/cupertino_http/example/integration_test/main.dart index 12128b5b9a..0d4d5e16d9 100644 --- a/pkgs/cupertino_http/example/integration_test/main.dart +++ b/pkgs/cupertino_http/example/integration_test/main.dart @@ -19,6 +19,7 @@ import 'url_session_delegate_test.dart' as url_session_delegate_test; import 'url_session_task_test.dart' as url_session_task_test; import 'url_session_test.dart' as url_session_test; import 'utils_test.dart' as utils_test; +import 'web_socket_conformance_test.dart' as web_socket_conformance_test; /// Execute all the tests in this directory. /// @@ -43,4 +44,5 @@ void main() { url_session_task_test.main(); url_session_test.main(); utils_test.main(); + web_socket_conformance_test.main(); } diff --git a/pkgs/cupertino_http/example/integration_test/utils_test.dart b/pkgs/cupertino_http/example/integration_test/utils_test.dart index 1bf1ca6f40..315282a6be 100644 --- a/pkgs/cupertino_http/example/integration_test/utils_test.dart +++ b/pkgs/cupertino_http/example/integration_test/utils_test.dart @@ -20,11 +20,11 @@ void main() { }); }); - group('stringDictToMap', () { + group('stringNSDictionaryToMap', () { test('empty input', () { final d = ncb.NSMutableDictionary.new1(linkedLibs); - expect(stringDictToMap(d), {}); + expect(stringNSDictionaryToMap(d), {}); }); test('single string input', () { @@ -32,7 +32,7 @@ void main() { ..setObject_forKey_( 'value'.toNSString(linkedLibs), 'key'.toNSString(linkedLibs)); - expect(stringDictToMap(d), {'key': 'value'}); + expect(stringNSDictionaryToMap(d), {'key': 'value'}); }); test('multiple string input', () { @@ -43,8 +43,31 @@ void main() { 'value2'.toNSString(linkedLibs), 'key2'.toNSString(linkedLibs)) ..setObject_forKey_( 'value3'.toNSString(linkedLibs), 'key3'.toNSString(linkedLibs)); - expect(stringDictToMap(d), + expect(stringNSDictionaryToMap(d), {'key1': 'value1', 'key2': 'value2', 'key3': 'value3'}); }); }); + + group('stringIterableToNSArray', () { + test('empty input', () { + final array = stringIterableToNSArray([]); + expect(array.count, 0); + }); + + test('single string input', () { + final array = stringIterableToNSArray(['apple']); + expect(array.count, 1); + expect( + ncb.NSString.castFrom(array.objectAtIndex_(0)).toString(), 'apple'); + }); + + test('multiple string input', () { + final array = stringIterableToNSArray(['apple', 'banana']); + expect(array.count, 2); + expect( + ncb.NSString.castFrom(array.objectAtIndex_(0)).toString(), 'apple'); + expect( + ncb.NSString.castFrom(array.objectAtIndex_(1)).toString(), 'banana'); + }); + }); } diff --git a/pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart b/pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart new file mode 100644 index 0000000000..8dff3a2626 --- /dev/null +++ b/pkgs/cupertino_http/example/integration_test/web_socket_conformance_test.dart @@ -0,0 +1,22 @@ +// 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 'package:cupertino_http/cupertino_http.dart'; +import 'package:test/test.dart'; +import 'package:web_socket_conformance_tests/web_socket_conformance_tests.dart'; + +void main() { + testAll(CupertinoWebSocket.connect); + + group('defaultSessionConfiguration', () { + testAll( + CupertinoWebSocket.connect, + ); + }); + group('fromSessionConfiguration', () { + final config = URLSessionConfiguration.ephemeralSessionConfiguration(); + testAll((uri, {protocols}) => + CupertinoWebSocket.connect(uri, protocols: protocols, config: config)); + }); +} diff --git a/pkgs/cupertino_http/example/pubspec.yaml b/pkgs/cupertino_http/example/pubspec.yaml index 8d61e62bed..f394051994 100644 --- a/pkgs/cupertino_http/example/pubspec.yaml +++ b/pkgs/cupertino_http/example/pubspec.yaml @@ -31,6 +31,8 @@ dev_dependencies: integration_test: sdk: flutter test: ^1.21.1 + web_socket_conformance_tests: + path: ../../web_socket_conformance_tests/ flutter: uses-material-design: true diff --git a/pkgs/cupertino_http/lib/cupertino_http.dart b/pkgs/cupertino_http/lib/cupertino_http.dart index 243ac81436..68691b8ab4 100644 --- a/pkgs/cupertino_http/lib/cupertino_http.dart +++ b/pkgs/cupertino_http/lib/cupertino_http.dart @@ -88,3 +88,4 @@ import 'src/cupertino_client.dart'; export 'src/cupertino_api.dart'; export 'src/cupertino_client.dart'; +export 'src/cupertino_web_socket.dart'; diff --git a/pkgs/cupertino_http/lib/src/cupertino_api.dart b/pkgs/cupertino_http/lib/src/cupertino_api.dart index 5cebd7ed42..780ddde0ec 100644 --- a/pkgs/cupertino_http/lib/src/cupertino_api.dart +++ b/pkgs/cupertino_http/lib/src/cupertino_api.dart @@ -352,7 +352,7 @@ class URLSessionConfiguration Map? get httpAdditionalHeaders { if (_nsObject.HTTPAdditionalHeaders case var additionalHeaders?) { final headers = ncb.NSDictionary.castFrom(additionalHeaders); - return stringDictToMap(headers); + return stringNSDictionaryToMap(headers); } return null; } @@ -628,7 +628,7 @@ class HTTPURLResponse extends URLResponse { Map get allHeaderFields { final headers = ncb.NSDictionary.castFrom(_httpUrlResponse.allHeaderFields!); - return stringDictToMap(headers); + return stringNSDictionaryToMap(headers); } @override @@ -992,7 +992,7 @@ class URLRequest extends _ObjectHolder { return null; } else { final headers = ncb.NSDictionary.castFrom(_nsObject.allHTTPHeaderFields!); - return stringDictToMap(headers); + return stringNSDictionaryToMap(headers); } } @@ -1584,4 +1584,35 @@ class URLSession extends _ObjectHolder { onWebSocketTaskClosed: _onWebSocketTaskClosed); return task; } + + /// Creates a [URLSessionWebSocketTask] that represents a connection to a + /// WebSocket endpoint. + /// + /// See [NSURLSession webSocketTaskWithURL:protocols:](https://developer.apple.com/documentation/foundation/nsurlsession/3181172-websockettaskwithurl) + URLSessionWebSocketTask webSocketTaskWithURL(Uri uri, + {Iterable? protocols}) { + if (_isBackground) { + throw UnsupportedError( + 'WebSocket tasks are not supported in background sessions'); + } + + final URLSessionWebSocketTask task; + if (protocols == null) { + task = URLSessionWebSocketTask._( + _nsObject.webSocketTaskWithURL_(uriToNSURL(uri))); + } else { + task = URLSessionWebSocketTask._( + _nsObject.webSocketTaskWithURL_protocols_( + uriToNSURL(uri), stringIterableToNSArray(protocols))); + } + _setupDelegation(_delegate, this, task, + onComplete: _onComplete, + onData: _onData, + onFinishedDownloading: _onFinishedDownloading, + onRedirect: _onRedirect, + onResponse: _onResponse, + onWebSocketTaskOpened: _onWebSocketTaskOpened, + onWebSocketTaskClosed: _onWebSocketTaskClosed); + return task; + } } diff --git a/pkgs/cupertino_http/lib/src/cupertino_web_socket.dart b/pkgs/cupertino_http/lib/src/cupertino_web_socket.dart new file mode 100644 index 0000000000..94246744c2 --- /dev/null +++ b/pkgs/cupertino_http/lib/src/cupertino_web_socket.dart @@ -0,0 +1,204 @@ +// 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:web_socket/web_socket.dart'; + +import 'cupertino_api.dart'; + +/// An error occurred while connecting to the peer. +class ConnectionException extends WebSocketException { + final Error 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). +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). + static Future connect(Uri url, + {Iterable? 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(); + 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. + // + // 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 occured (e.g. network failure) and `_connectionClosed` + // will signal that and close `event`. + webSocket._connectionClosed( + 1006, Data.fromList('abnormal close'.codeUnits)); + } + }); + + session.webSocketTaskWithURL(url, protocols: protocols).resume(); + return readyCompleter.future; + } + + final URLSessionWebSocketTask _task; + final String _protocol; + final _events = StreamController(); + + CupertinoWebSocket._(this._task, this._protocol) { + _scheduleReceive(); + } + + /// Handle an incoming message from the peer and schedule receiving the next + /// message. + void _handleMessage(URLSessionWebSocketMessage value) { + late WebSocketEvent event; + switch (value.type) { + case URLSessionWebSocketMessageType.urlSessionWebSocketMessageTypeString: + event = TextDataReceived(value.string!); + break; + case URLSessionWebSocketMessageType.urlSessionWebSocketMessageTypeData: + event = BinaryDataReceived(value.data!.bytes); + 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 Error) { + if (e.domain == '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, e.code]) { + ['NSPOSIXErrorDomain', 100] => (1002, e.localizedDescription), + _ => (1006, e.localizedDescription) + }; + _task.cancel(); + _connectionClosed( + code, reason == null ? null : Data.fromList(reason.codeUnits)); + } else { + throw StateError('unexpected error: $e'); + } + } + + void _connectionClosed(int? closeCode, Data? reason) { + if (!_events.isClosed) { + final closeReason = reason == null ? '' : utf8.decode(reason.bytes); + + _events + ..add(CloseReceived(closeCode, closeReason)) + ..close(); + } + } + + @override + void sendBytes(Uint8List b) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + _task + .sendMessage(URLSessionWebSocketMessage.fromData(Data.fromList(b))) + .then((_) => _, onError: _closeConnectionWithError); + } + + @override + void sendText(String s) { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + _task + .sendMessage(URLSessionWebSocketMessage.fromString(s)) + .then((_) => _, onError: _closeConnectionWithError); + } + + @override + Future close([int? code, String? reason]) async { + if (_events.isClosed) { + throw StateError('WebSocket is closed'); + } + + if (code != null) { + RangeError.checkValueInInterval(code, 3000, 4999, 'code'); + } + 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, Data.fromList(utf8.encode(reason))); + } else { + _task.cancel(); + } + } + } + + @override + Stream get events => _events.stream; + + @override + String get protocol => _protocol; +} diff --git a/pkgs/cupertino_http/lib/src/utils.dart b/pkgs/cupertino_http/lib/src/utils.dart index be23e5cdcb..02fc5489b1 100644 --- a/pkgs/cupertino_http/lib/src/utils.dart +++ b/pkgs/cupertino_http/lib/src/utils.dart @@ -59,7 +59,7 @@ String? toStringOrNull(ncb.NSString? s) { /// Converts a NSDictionary containing NSString keys and NSString values into /// an equivalent map. -Map stringDictToMap(ncb.NSDictionary d) { +Map stringNSDictionaryToMap(ncb.NSDictionary d) { // TODO(https://github.com/dart-lang/ffigen/issues/374): Make this // function type safe. Currently it will unconditionally cast both keys and // values to NSString with a likely crash down the line if that isn't their @@ -78,5 +78,16 @@ Map stringDictToMap(ncb.NSDictionary d) { return m; } +ncb.NSArray stringIterableToNSArray(Iterable strings) { + final array = + ncb.NSMutableArray.arrayWithCapacity_(linkedLibs, strings.length); + + var index = 0; + for (var s in strings) { + array.setObject_atIndexedSubscript_(s.toNSString(linkedLibs), index++); + } + return array; +} + ncb.NSURL uriToNSURL(Uri uri) => ncb.NSURL.URLWithString_(linkedLibs, uri.toString().toNSString(linkedLibs)); diff --git a/pkgs/cupertino_http/pubspec.yaml b/pkgs/cupertino_http/pubspec.yaml index 2a819e120b..9d375981d0 100644 --- a/pkgs/cupertino_http/pubspec.yaml +++ b/pkgs/cupertino_http/pubspec.yaml @@ -1,13 +1,15 @@ name: cupertino_http -version: 1.3.1-wip +version: 1.4.0-wip +publish_to: none # Do not merge with this here! + description: >- A macOS/iOS Flutter plugin that provides access to the Foundation URL Loading System. repository: https://github.com/dart-lang/http/tree/master/pkgs/cupertino_http environment: - sdk: ^3.2.0 - flutter: '>=3.16.0' # If changed, update test matrix. + sdk: ^3.3.0 + flutter: '>=3.19.0' # If changed, update test matrix. dependencies: async: ^2.5.0 @@ -15,6 +17,8 @@ dependencies: flutter: sdk: flutter http: ^1.2.0 + web_socket: + path: ../web_socket dev_dependencies: dart_flutter_team_lints: ^2.0.0