Skip to content

Commit 6823c84

Browse files
committed
Add WebSocket transport for Flutter Web IRC
1 parent a72a6a4 commit 6823c84

16 files changed

Lines changed: 300 additions & 39 deletions

lib/core/models/network_config.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ class NetworkConfig {
1515
this.username = 'androidircx',
1616
this.realName = 'AndroidIRCX',
1717
this.useTls = true,
18+
this.webSocketPort,
19+
this.webSocketPath,
1820
this.password,
1921
this.saslAccount,
2022
this.saslPassword,
@@ -31,6 +33,8 @@ class NetworkConfig {
3133
final String username;
3234
final String realName;
3335
final bool useTls;
36+
final int? webSocketPort;
37+
final String? webSocketPath;
3438
final String? password;
3539
final String? saslAccount;
3640
final String? saslPassword;
@@ -47,6 +51,8 @@ class NetworkConfig {
4751
String? username,
4852
String? realName,
4953
bool? useTls,
54+
int? webSocketPort,
55+
String? webSocketPath,
5056
String? password,
5157
String? saslAccount,
5258
String? saslPassword,
@@ -63,6 +69,8 @@ class NetworkConfig {
6369
username: username ?? this.username,
6470
realName: realName ?? this.realName,
6571
useTls: useTls ?? this.useTls,
72+
webSocketPort: webSocketPort ?? this.webSocketPort,
73+
webSocketPath: webSocketPath ?? this.webSocketPath,
6674
password: password ?? this.password,
6775
saslAccount: saslAccount ?? this.saslAccount,
6876
saslPassword: saslPassword ?? this.saslPassword,
@@ -82,6 +90,8 @@ class NetworkConfig {
8290
'username': username,
8391
'realName': realName,
8492
'useTls': useTls,
93+
'webSocketPort': webSocketPort,
94+
'webSocketPath': webSocketPath,
8595
'password': password,
8696
'saslAccount': saslAccount,
8797
'saslPassword': saslPassword,
@@ -101,6 +111,8 @@ class NetworkConfig {
101111
username: (json['username'] as String?) ?? 'androidircx',
102112
realName: (json['realName'] as String?) ?? 'AndroidIRCX',
103113
useTls: (json['useTls'] as bool?) ?? true,
114+
webSocketPort: (json['webSocketPort'] as num?)?.toInt(),
115+
webSocketPath: json['webSocketPath'] as String?,
104116
password: json['password'] as String?,
105117
saslAccount: json['saslAccount'] as String?,
106118
saslPassword: json['saslPassword'] as String?,

lib/core/storage/in_memory_network_repository.dart

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class InMemoryNetworkRepository implements NetworkRepository {
1414
name: 'DBase',
1515
host: 'irc.dbase.in.rs',
1616
port: 6697,
17+
webSocketPort: 16697,
1718
nickname: 'AndroidIRCX',
1819
altNickname: 'AndroidIRCX_',
1920
useTls: true,
@@ -27,17 +28,27 @@ class InMemoryNetworkRepository implements NetworkRepository {
2728

2829
@override
2930
Future<List<NetworkConfig>> loadNetworks() async {
30-
return List<NetworkConfig>.unmodifiable(_networks);
31+
return List<NetworkConfig>.unmodifiable(
32+
_networks.map(_normalizeNetwork),
33+
);
3134
}
3235

3336
@override
3437
Future<void> saveNetwork(NetworkConfig network) async {
3538
final index = _networks.indexWhere((item) => item.id == network.id);
3639
if (index == -1) {
37-
_networks.add(network);
40+
_networks.add(_normalizeNetwork(network));
3841
return;
3942
}
4043

41-
_networks[index] = network;
44+
_networks[index] = _normalizeNetwork(network);
45+
}
46+
47+
NetworkConfig _normalizeNetwork(NetworkConfig network) {
48+
if (network.host == 'irc.dbase.in.rs' && network.webSocketPort == null) {
49+
return network.copyWith(webSocketPort: 16697);
50+
}
51+
52+
return network;
4253
}
4354
}

lib/core/storage/shared_prefs_network_repository.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class SharedPrefsNetworkRepository implements NetworkRepository {
2626
final decoded = jsonDecode(raw) as List<dynamic>;
2727
return decoded
2828
.map((item) => NetworkConfig.fromJson(item as Map<String, Object?>))
29+
.map(_normalizeNetwork)
2930
.toList(growable: false);
3031
}
3132

@@ -48,12 +49,21 @@ class SharedPrefsNetworkRepository implements NetworkRepository {
4849
);
4950
}
5051

52+
NetworkConfig _normalizeNetwork(NetworkConfig network) {
53+
if (network.host == 'irc.dbase.in.rs' && network.webSocketPort == null) {
54+
return network.copyWith(webSocketPort: 16697);
55+
}
56+
57+
return network;
58+
}
59+
5160
static const List<NetworkConfig> _defaultSeed = [
5261
NetworkConfig(
5362
id: 'dbase',
5463
name: 'DBase',
5564
host: 'irc.dbase.in.rs',
5665
port: 6697,
66+
webSocketPort: 16697,
5767
nickname: 'AndroidIRCX',
5868
altNickname: 'AndroidIRCX_',
5969
useTls: true,

lib/features/connections/application/network_list_controller.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class NetworkListController extends ChangeNotifier {
3232
required String nickname,
3333
required String altNickname,
3434
required bool useTls,
35+
int? webSocketPort,
36+
String? webSocketPath,
3537
required bool autoConnect,
3638
required SaslMechanism saslMechanism,
3739
String? saslAccount,
@@ -46,6 +48,8 @@ class NetworkListController extends ChangeNotifier {
4648
nickname: nickname,
4749
altNickname: altNickname.trim(),
4850
useTls: useTls,
51+
webSocketPort: webSocketPort,
52+
webSocketPath: (webSocketPath ?? '').trim().isEmpty ? null : webSocketPath?.trim(),
4953
autoConnect: autoConnect,
5054
saslMechanism: saslMechanism,
5155
saslAccount: (saslAccount ?? '').trim().isEmpty ? null : saslAccount?.trim(),

lib/features/connections/presentation/network_form_screen.dart

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class NetworkFormResult {
99
required this.nickname,
1010
required this.altNickname,
1111
required this.useTls,
12+
this.webSocketPort,
13+
this.webSocketPath,
1214
required this.autoConnect,
1315
required this.saslMechanism,
1416
this.saslAccount,
@@ -21,6 +23,8 @@ class NetworkFormResult {
2123
final String nickname;
2224
final String altNickname;
2325
final bool useTls;
26+
final int? webSocketPort;
27+
final String? webSocketPath;
2428
final bool autoConnect;
2529
final SaslMechanism saslMechanism;
2630
final String? saslAccount;
@@ -44,6 +48,8 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
4448
late final TextEditingController _nameController;
4549
late final TextEditingController _hostController;
4650
late final TextEditingController _portController;
51+
late final TextEditingController _webSocketPortController;
52+
late final TextEditingController _webSocketPathController;
4753
late final TextEditingController _nicknameController;
4854
late final TextEditingController _altNicknameController;
4955
late final TextEditingController _saslAccountController;
@@ -61,6 +67,12 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
6167
_portController = TextEditingController(
6268
text: (initial?.port ?? 6697).toString(),
6369
);
70+
_webSocketPortController = TextEditingController(
71+
text: initial?.webSocketPort?.toString() ?? '',
72+
);
73+
_webSocketPathController = TextEditingController(
74+
text: initial?.webSocketPath ?? '',
75+
);
6476
_nicknameController = TextEditingController(
6577
text: initial?.nickname ?? 'AndroidIRCX',
6678
);
@@ -79,6 +91,8 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
7991
_nameController.dispose();
8092
_hostController.dispose();
8193
_portController.dispose();
94+
_webSocketPortController.dispose();
95+
_webSocketPathController.dispose();
8296
_nicknameController.dispose();
8397
_altNicknameController.dispose();
8498
_saslAccountController.dispose();
@@ -128,6 +142,42 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
128142
},
129143
),
130144
const SizedBox(height: 16),
145+
TextFormField(
146+
controller: _webSocketPortController,
147+
keyboardType: TextInputType.number,
148+
decoration: const InputDecoration(
149+
labelText: 'WebSocket port',
150+
helperText: 'Optional. Used by Flutter Web instead of raw IRC port.',
151+
),
152+
validator: (value) {
153+
if ((value ?? '').trim().isEmpty) {
154+
return null;
155+
}
156+
if (int.tryParse(value!.trim()) == null) {
157+
return 'Enter a valid WebSocket port.';
158+
}
159+
return null;
160+
},
161+
),
162+
const SizedBox(height: 16),
163+
TextFormField(
164+
controller: _webSocketPathController,
165+
decoration: const InputDecoration(
166+
labelText: 'WebSocket path',
167+
helperText: 'Optional. Example: /irc or /websocket. Leave empty for root path.',
168+
),
169+
validator: (value) {
170+
final trimmed = (value ?? '').trim();
171+
if (trimmed.isEmpty) {
172+
return null;
173+
}
174+
if (!trimmed.startsWith('/')) {
175+
return 'WebSocket path must start with /.';
176+
}
177+
return null;
178+
},
179+
),
180+
const SizedBox(height: 16),
131181
TextFormField(
132182
controller: _nicknameController,
133183
decoration: const InputDecoration(labelText: 'Nickname'),
@@ -239,6 +289,10 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
239289
nickname: _nicknameController.text.trim(),
240290
altNickname: _altNicknameController.text.trim(),
241291
useTls: _useTls,
292+
webSocketPort: (_webSocketPortController.text.trim().isEmpty)
293+
? null
294+
: int.parse(_webSocketPortController.text.trim()),
295+
webSocketPath: _webSocketPathController.text.trim(),
242296
autoConnect: _autoConnect,
243297
saslMechanism: _saslMechanism,
244298
saslAccount: _saslAccountController.text.trim(),

lib/features/connections/presentation/network_list_screen.dart

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,15 @@ class NetworkListScreen extends StatelessWidget {
107107
return;
108108
}
109109

110-
await controller.saveNetwork(
110+
await controller.saveNetwork(
111111
name: result.name,
112112
host: result.host,
113113
port: result.port,
114114
nickname: result.nickname,
115115
altNickname: result.altNickname,
116116
useTls: result.useTls,
117+
webSocketPort: result.webSocketPort,
118+
webSocketPath: result.webSocketPath,
117119
autoConnect: result.autoConnect,
118120
saslMechanism: result.saslMechanism,
119121
saslAccount: result.saslAccount,
@@ -230,7 +232,7 @@ class _NetworkCard extends StatelessWidget {
230232
Text('${network.host}:${network.port}'),
231233
const SizedBox(height: 4),
232234
Text(
233-
'Nick: ${network.nickname} / ${network.altNickname ?? '${network.nickname}_'} • ${network.useTls ? 'TLS' : 'Plain TCP'}',
235+
'Nick: ${network.nickname} / ${network.altNickname ?? '${network.nickname}_'} • ${network.useTls ? 'TLS' : 'Plain TCP'}${network.webSocketPort == null ? '' : ' • WS ${network.webSocketPort}${(network.webSocketPath ?? '').isEmpty ? '' : network.webSocketPath}'}',
234236
style: theme.textTheme.bodySmall,
235237
),
236238
if (network.autoConnect) ...[

lib/irc/parser/irc_url_parser.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ NetworkConfig toTemporaryNetworkConfig(
190190
realName: parsedUrl.realName ?? defaultRealName,
191191
username: parsedUrl.ident ?? defaultUsername,
192192
useTls: parsedUrl.ssl,
193+
webSocketPort: parsedUrl.ssl ? parsedUrl.port : null,
193194
password: parsedUrl.password,
194195
);
195196
}

lib/irc/services/irc_service.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class IrcService {
1414
IrcService({
1515
IrcTransportConnector? transportConnector,
1616
String Function()? scramNonceGenerator,
17-
}) : _transportConnector = transportConnector ?? SocketIrcTransport.connect,
17+
}) : _transportConnector = transportConnector ?? defaultIrcTransportConnector,
1818
_scramNonceGenerator = scramNonceGenerator,
1919
_state = const ConnectionSnapshot(
2020
networkId: '',
Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,25 @@
1-
import 'dart:convert';
2-
import 'dart:io';
3-
41
import 'package:androidircx/core/models/network_config.dart';
2+
import 'package:androidircx/irc/services/irc_transport_connector_stub.dart'
3+
if (dart.library.io) 'package:androidircx/irc/services/irc_transport_connector_io.dart'
4+
if (dart.library.js_interop) 'package:androidircx/irc/services/irc_transport_connector_web.dart'
5+
as transport_connector;
56

67
abstract class IrcTransport {
78
Stream<String> get lines;
89
Future<void> sendLine(String line);
910
Future<void> close();
1011
}
1112

12-
class SocketIrcTransport implements IrcTransport {
13-
SocketIrcTransport._(this._socket)
14-
: lines = _socket
15-
.cast<List<int>>()
16-
.transform(utf8.decoder)
17-
.transform(const LineSplitter())
18-
.where((line) => line.isNotEmpty)
19-
.asBroadcastStream();
20-
21-
final Socket _socket;
22-
23-
@override
24-
final Stream<String> lines;
25-
26-
static Future<SocketIrcTransport> connect(NetworkConfig network) async {
27-
final socket = network.useTls
28-
? await SecureSocket.connect(network.host, network.port)
29-
: await Socket.connect(network.host, network.port);
30-
return SocketIrcTransport._(socket);
31-
}
32-
33-
@override
34-
Future<void> close() async {
35-
_socket.destroy();
36-
}
13+
Future<IrcTransport> defaultIrcTransportConnector(NetworkConfig network) {
14+
return transport_connector.connectDefaultTransport(network);
15+
}
3716

38-
@override
39-
Future<void> sendLine(String line) async {
40-
_socket.write('$line\r\n');
41-
await _socket.flush();
42-
}
17+
Uri buildWebSocketUri(NetworkConfig network) {
18+
final scheme = network.useTls ? 'wss' : 'ws';
19+
final port = network.webSocketPort ?? network.port;
20+
final rawPath = (network.webSocketPath ?? '').trim();
21+
final normalizedPath = rawPath.isEmpty
22+
? '/'
23+
: (rawPath.startsWith('/') ? rawPath : '/$rawPath');
24+
return Uri.parse('$scheme://${network.host}:$port$normalizedPath');
4325
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:androidircx/core/models/network_config.dart';
5+
import 'package:androidircx/irc/services/irc_transport.dart';
6+
7+
Future<IrcTransport> connectDefaultTransport(NetworkConfig network) {
8+
return SocketIrcTransport.connect(network);
9+
}
10+
11+
class SocketIrcTransport implements IrcTransport {
12+
SocketIrcTransport._(this._socket)
13+
: lines = _socket
14+
.cast<List<int>>()
15+
.transform(utf8.decoder)
16+
.transform(const LineSplitter())
17+
.where((line) => line.isNotEmpty)
18+
.asBroadcastStream();
19+
20+
final Socket _socket;
21+
22+
@override
23+
final Stream<String> lines;
24+
25+
static Future<SocketIrcTransport> connect(NetworkConfig network) async {
26+
final socket = network.useTls
27+
? await SecureSocket.connect(network.host, network.port)
28+
: await Socket.connect(network.host, network.port);
29+
return SocketIrcTransport._(socket);
30+
}
31+
32+
@override
33+
Future<void> close() async {
34+
_socket.destroy();
35+
}
36+
37+
@override
38+
Future<void> sendLine(String line) async {
39+
_socket.write('$line\r\n');
40+
await _socket.flush();
41+
}
42+
}

0 commit comments

Comments
 (0)