Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 41 additions & 25 deletions lib/common/request.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8List>(
newUrl,
options: Options(
Expand Down Expand Up @@ -121,36 +121,52 @@ class Request {
};

Future<Result<IpInfo?>> checkIp({CancelToken? cancelToken}) async {
var failureCount = 0;
final futures = _ipInfoSources.entries.map((source) async {
final completer = Completer<Result<IpInfo?>>();
final future = Dio().get<Map<String, dynamic>>(
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<IpInfo?> _checkIpFromSource(
MapEntry<String, IpInfo Function(Map<String, dynamic>)> source, {
CancelToken? cancelToken,
}) async {
try {
final response = await Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
),
).get<Map<String, dynamic>>(
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<bool> pingHelper() async {
Expand Down
22 changes: 19 additions & 3 deletions lib/models/profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,13 @@ extension ProfileExtension on Profile {
}

Future<Profile> 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());
}

Expand All @@ -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<void> _writeProfileFileAtomically(File file, List<int> 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;
}
}
}
86 changes: 65 additions & 21 deletions lib/pages/send_to_tv_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,87 @@ class SendToTvPage extends ConsumerStatefulWidget {
}

class _SendToTvPageState extends ConsumerState<SendToTvPage> {
static const _syncType = 'flclashx_tv_sync';

final MobileScannerController _scannerController = MobileScannerController();
bool _isScanComplete = false;

Future<void> _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<String, dynamic>) {
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(
Expand Down
46 changes: 35 additions & 11 deletions lib/views/profiles/receive_profile_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class ReceiveProfileDialog extends StatefulWidget {
}

class _ReceiveProfileDialogState extends State<ReceiveProfileDialog> {
static const _syncType = 'flclashx_tv_sync';
static const _syncPort = 8899;

HttpServer? _server;
String? _qrData;
bool _isLoading = true;
Expand All @@ -30,43 +33,64 @@ class _ReceiveProfileDialogState extends State<ReceiveProfileDialog> {
Future<void> _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<String?> _readProfileUrl(shelf.Request request) async {
try {
final body = await request.readAsString();
final payload = jsonDecode(body);
if (payload is! Map<String, dynamic>) {
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();
}

Expand Down Expand Up @@ -107,4 +131,4 @@ class _ReceiveProfileDialogState extends State<ReceiveProfileDialog> {
],
);
}
}
}