From b637233529ef01d63485fb8e1bf3d43b3b73a4b2 Mon Sep 17 00:00:00 2001 From: Ivan Zorin Date: Thu, 28 May 2026 03:33:39 +0300 Subject: [PATCH 1/3] Stabilize TV profile sync --- lib/pages/send_to_tv_page.dart | 86 ++++++++++++++----- .../profiles/receive_profile_dialog.dart | 46 +++++++--- 2 files changed, 100 insertions(+), 32 deletions(-) diff --git a/lib/pages/send_to_tv_page.dart b/lib/pages/send_to_tv_page.dart index e9bb8eb5c..61a4ffe01 100644 --- a/lib/pages/send_to_tv_page.dart +++ b/lib/pages/send_to_tv_page.dart @@ -18,43 +18,87 @@ class SendToTvPage extends ConsumerStatefulWidget { } class _SendToTvPageState extends ConsumerState { + static const _syncType = 'flclashx_tv_sync'; + final MobileScannerController _scannerController = MobileScannerController(); bool _isScanComplete = false; Future _handleQrCode(BarcodeCapture capture) async { if (_isScanComplete) return; - setState(() { - _isScanComplete = true; - }); + if (capture.barcodes.isEmpty) { + return; + } final rawValue = capture.barcodes.first.rawValue; - if (rawValue == null) return; + if (rawValue == null) { + return; + } try { - final data = jsonDecode(rawValue); - if (data['type'] == 'flclashx_tv_sync') { - final ip = data['ip']; - final port = data['port']; - final tvUrl = 'http://$ip:$port/add-profile'; - final dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 5), - )); - await dio.post( - tvUrl, - data: {'url': widget.profileUrl}, - ); - _showResultDialog(appLocalizations.successTitle, - appLocalizations.sentSuccessfullyMessage); + final endpoint = _parseSyncEndpoint(rawValue); + if (endpoint == null) { + return; } + setState(() { + _isScanComplete = true; + }); + final dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + )); + await dio.post( + endpoint, + data: {'url': widget.profileUrl}, + ); + if (!mounted) return; + _showResultDialog( + appLocalizations.successTitle, + appLocalizations.sentSuccessfullyMessage, + ); } catch (e) { + commonPrint.log('Error sending profile to TV: $e'); + if (!mounted) return; + setState(() { + _isScanComplete = true; + }); _showResultDialog( - appLocalizations.errorTitle, appLocalizations.invalidQrMessage); - print('Error sending to TV: $e'); + appLocalizations.errorTitle, + appLocalizations.invalidQrMessage, + ); + } + } + + String? _parseSyncEndpoint(String rawValue) { + final dynamic payload; + try { + payload = jsonDecode(rawValue); + } catch (_) { + return null; + } + if (payload is! Map) { + return null; + } + if (payload['type'] != _syncType) { + return null; + } + final ip = payload['ip']; + final port = payload['port']; + if (ip is! String || ip.isEmpty || port is! int) { + return null; + } + if (port < 1 || port > 65535) { + return null; } + return Uri( + scheme: 'http', + host: ip, + port: port, + path: '/add-profile', + ).toString(); } void _showResultDialog(String title, String content) { + if (!mounted) return; showDialog( context: context, builder: (_) => AlertDialog( diff --git a/lib/views/profiles/receive_profile_dialog.dart b/lib/views/profiles/receive_profile_dialog.dart index b322c3ba4..988eda64a 100644 --- a/lib/views/profiles/receive_profile_dialog.dart +++ b/lib/views/profiles/receive_profile_dialog.dart @@ -17,6 +17,9 @@ class ReceiveProfileDialog extends StatefulWidget { } class _ReceiveProfileDialogState extends State { + static const _syncType = 'flclashx_tv_sync'; + static const _syncPort = 8899; + HttpServer? _server; String? _qrData; bool _isLoading = true; @@ -30,43 +33,64 @@ class _ReceiveProfileDialogState extends State { Future _startServerAndGenerateQr() async { try { final ip = await NetworkInfo().getWifiIP(); - const port = 8899; + if (ip == null || ip.isEmpty) { + throw StateError('Could not get IP address'); + } final router = shelf_router.Router(); router.post('/add-profile', (shelf.Request request) async { - final body = await request.readAsString(); - final json = jsonDecode(body); - final url = json['url'] as String?; + final url = await _readProfileUrl(request); if (url != null && url.isNotEmpty) { - print('Received subscription link: $url'); + commonPrint.log('Received subscription link from TV sync'); if (mounted) Navigator.of(context).pop(url); return shelf.Response.ok('Link received by TV'); } return shelf.Response.badRequest(body: 'URL not found'); }); - _server = await shelf_io.serve(router.call, ip!, port); - print('Server started at http://${_server?.address.host}:${_server?.port}'); + _server = await shelf_io.serve(router.call, ip, _syncPort); + commonPrint.log( + 'TV sync server started at http://${_server?.address.host}:${_server?.port}', + ); + if (!mounted) return; setState(() { _qrData = jsonEncode({ - 'type': 'flclashx_tv_sync', + 'type': _syncType, 'ip': _server?.address.host, 'port': _server?.port, }); _isLoading = false; }); } catch (e) { - print('Error starting server: $e'); + commonPrint.log('Error starting TV sync server: $e'); if (mounted) Navigator.of(context).pop(); } } + Future _readProfileUrl(shelf.Request request) async { + try { + final body = await request.readAsString(); + final payload = jsonDecode(body); + if (payload is! Map) { + return null; + } + final url = payload['url']; + if (url is! String || url.trim().isEmpty) { + return null; + } + return url.trim(); + } catch (e) { + commonPrint.log('Invalid TV sync request: $e'); + return null; + } + } + @override void dispose() { _server?.close(force: true); - print('Server stopped'); + commonPrint.log('TV sync server stopped'); super.dispose(); } @@ -107,4 +131,4 @@ class _ReceiveProfileDialogState extends State { ], ); } -} \ No newline at end of file +} From 31073ad29e1ee487de30a7fedd10dc4ca6a25695 Mon Sep 17 00:00:00 2001 From: Ivan Zorin Date: Thu, 28 May 2026 03:33:43 +0300 Subject: [PATCH 2/3] Write profiles atomically --- lib/models/profile.dart | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/models/profile.dart b/lib/models/profile.dart index 2a6644255..400de00b6 100644 --- a/lib/models/profile.dart +++ b/lib/models/profile.dart @@ -233,12 +233,13 @@ extension ProfileExtension on Profile { } Future saveFile(Uint8List bytes) async { - final message = await clashCore.validateConfig(utf8.decode(bytes)); + final content = utf8.decode(bytes); + final message = await clashCore.validateConfig(content); if (message.isNotEmpty) { throw message; } final file = await getFile(); - await file.writeAsBytes(bytes); + await _writeProfileFileAtomically(file, bytes); return copyWith(lastUpdateDate: DateTime.now()); } @@ -248,7 +249,22 @@ extension ProfileExtension on Profile { throw message; } final file = await getFile(); - await file.writeAsString(value); + await _writeProfileFileAtomically(file, utf8.encode(value)); return copyWith(lastUpdateDate: DateTime.now()); } + + Future _writeProfileFileAtomically(File file, List bytes) async { + final tempFile = File( + '${file.path}.tmp.${DateTime.now().microsecondsSinceEpoch}', + ); + try { + await tempFile.writeAsBytes(bytes, flush: true); + await tempFile.rename(file.path); + } catch (_) { + if (await tempFile.exists()) { + await tempFile.delete(); + } + rethrow; + } + } } From 254846ab42e958ad377493731abcbb8917dec97a Mon Sep 17 00:00:00 2001 From: Ivan Zorin Date: Thu, 28 May 2026 03:33:46 +0300 Subject: [PATCH 3/3] Harden IP detection requests --- lib/common/request.dart | 66 +++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/lib/common/request.dart b/lib/common/request.dart index 5ebbd4cc5..d4e48b39b 100644 --- a/lib/common/request.dart +++ b/lib/common/request.dart @@ -57,7 +57,7 @@ class Request { throw Exception('Redirect detected, but no location header was found.'); } - print('↪️ Redirecting to: $newUrl'); + commonPrint.log('Redirecting to: $newUrl'); final finalResponse = await _dio.get( newUrl, options: Options( @@ -121,36 +121,52 @@ class Request { }; Future> checkIp({CancelToken? cancelToken}) async { - var failureCount = 0; - final futures = _ipInfoSources.entries.map((source) async { - final completer = Completer>(); - final future = Dio().get>( + final futures = _ipInfoSources.entries.map( + (source) => _checkIpFromSource(source, cancelToken: cancelToken), + ).toList(); + await for (final ipInfo in Stream.fromFutures(futures)) { + if (cancelToken?.isCancelled == true) { + return Result.error("cancelled"); + } + if (ipInfo != null) { + cancelToken?.cancel(); + return Result.success(ipInfo); + } + } + return Result.success(null); + } + + Future _checkIpFromSource( + MapEntry)> source, { + CancelToken? cancelToken, + }) async { + try { + final response = await Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + ), + ).get>( source.key, cancelToken: cancelToken, options: Options( responseType: ResponseType.json, ), ); - future.then((res) { - if (res.statusCode == HttpStatus.ok && res.data != null) { - completer.complete(Result.success(source.value(res.data!))); - } else { - failureCount++; - if (failureCount == _ipInfoSources.length) { - completer.complete(Result.success(null)); - } - } - }).catchError((e) { - failureCount++; - if (e == DioExceptionType.cancel) { - completer.complete(Result.error("cancelled")); - } - }); - return completer.future; - }); - final res = await Future.any(futures); - cancelToken?.cancel(); - return res; + if (response.statusCode != HttpStatus.ok || response.data == null) { + return null; + } + return source.value(response.data!); + } on DioException catch (e) { + if (CancelToken.isCancel(e)) { + return null; + } + commonPrint.log('Failed to check IP from ${source.key}: $e'); + return null; + } catch (e) { + commonPrint.log('Failed to parse IP info from ${source.key}: $e'); + return null; + } } Future pingHelper() async {