diff --git a/README.md b/README.md index d018fd8..2ef365c 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,7 @@ flutter pub run build_runner build --delete-conflicting-outputs If you protect Open‑WebUI with SSO or a reverse proxy (Authlia, Authentik, etc.), whitelist these path prefixes so Conduit can complete login, sync, and -streaming flows. Paths are relative to your server base URL; replace tokens like -`{chatId}` with actual identifiers. +streaming flows. Paths are relative to your server base URL. - `/health` - `/api/*` diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 09307a7..c990258 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - DKImagePickerController/Core (4.3.9): - DKImagePickerController/ImageDataManager - DKImagePickerController/Resource @@ -82,6 +84,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) @@ -110,6 +113,8 @@ SPEC REPOS: - SwiftyGif EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" Flutter: @@ -150,6 +155,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be diff --git a/lib/core/models/server_config.dart b/lib/core/models/server_config.dart index 65b31e5..ac6a17d 100644 --- a/lib/core/models/server_config.dart +++ b/lib/core/models/server_config.dart @@ -13,6 +13,9 @@ sealed class ServerConfig with _$ServerConfig { @Default({}) Map customHeaders, DateTime? lastConnected, @Default(false) bool isActive, + + /// Whether to trust self-signed TLS certificates for this server. + @Default(false) bool allowSelfSignedCertificates, }) = _ServerConfig; factory ServerConfig.fromJson(Map json) => diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index ea73906..ce2219e 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -1,8 +1,9 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter/foundation.dart'; import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter/foundation.dart'; // import 'package:http_parser/http_parser.dart'; // Removed legacy websocket/socket.io imports import 'package:uuid/uuid.dart'; @@ -62,6 +63,8 @@ class ApiService { : null, ), ) { + _configureSelfSignedSupport(); + // Use API key from server config if provided and no explicit auth token final effectiveAuthToken = authToken ?? serverConfig.apiKey; @@ -125,6 +128,55 @@ class ApiService { } } + void _configureSelfSignedSupport() { + if (kIsWeb || !serverConfig.allowSelfSignedCertificates) { + return; + } + + final baseUri = _parseBaseUri(serverConfig.url); + if (baseUri == null) { + return; + } + + final adapter = _dio.httpClientAdapter; + if (adapter is! IOHttpClientAdapter) { + return; + } + + adapter.createHttpClient = () { + final client = HttpClient(); + final host = baseUri.host.toLowerCase(); + final port = baseUri.hasPort ? baseUri.port : null; + client.badCertificateCallback = + (X509Certificate cert, String requestHost, int requestPort) { + if (requestHost.toLowerCase() != host) { + return false; + } + if (port == null) { + return true; + } + return requestPort == port; + }; + return client; + }; + } + + Uri? _parseBaseUri(String baseUrl) { + final trimmed = baseUrl.trim(); + if (trimmed.isEmpty) { + return null; + } + Uri? parsed = Uri.tryParse(trimmed); + if (parsed == null) { + return null; + } + if (!parsed.hasScheme) { + parsed = + Uri.tryParse('https://$trimmed') ?? Uri.tryParse('http://$trimmed'); + } + return parsed; + } + // Health check Future checkHealth() async { try { diff --git a/lib/core/services/background_streaming_handler.dart b/lib/core/services/background_streaming_handler.dart index 7670a44..d271254 100644 --- a/lib/core/services/background_streaming_handler.dart +++ b/lib/core/services/background_streaming_handler.dart @@ -217,7 +217,11 @@ class BackgroundStreamingHandler { DebugLogger.stream( 'saveStreamStatesForRecovery called', scope: 'background', - data: {'streamIds': streamIds, 'reason': reason, 'statesCount': _streamStates.length}, + data: { + 'streamIds': streamIds, + 'reason': reason, + 'statesCount': _streamStates.length, + }, ); final statesToSave = streamIds diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index 777e449..73bec77 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -9,6 +9,7 @@ import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; import '../utils/debug_logger.dart'; import 'secure_credential_storage.dart'; +import 'self_signed_certificate_manager.dart'; /// Optimized storage service backed by Hive for non-sensitive data and /// FlutterSecureStorage for credentials. @@ -196,6 +197,9 @@ class OptimizedStorageService { await _secureCredentialStorage.saveServerConfigs(jsonString); _cache['server_config_count'] = configs.length; _cacheTimestamps['server_config_count'] = DateTime.now(); + SelfSignedCertificateManager.instance + ..ensureInitialized() + ..updateTrustedServers(configs); DebugLogger.log( 'Server configs saved (${configs.length} entries)', scope: 'storage/optimized', @@ -215,6 +219,9 @@ class OptimizedStorageService { if (jsonString == null || jsonString.isEmpty) { _cache['server_config_count'] = 0; _cacheTimestamps['server_config_count'] = DateTime.now(); + SelfSignedCertificateManager.instance + ..ensureInitialized() + ..clearTrustedServers(); return const []; } @@ -224,12 +231,16 @@ class OptimizedStorageService { .toList(); _cache['server_config_count'] = configs.length; _cacheTimestamps['server_config_count'] = DateTime.now(); + SelfSignedCertificateManager.instance + ..ensureInitialized() + ..updateTrustedServers(configs); return configs; } catch (error) { DebugLogger.log( 'Failed to retrieve server configs: $error', scope: 'storage/optimized', ); + SelfSignedCertificateManager.instance.clearTrustedServers(); return const []; } } diff --git a/lib/core/services/persistent_streaming_service.dart b/lib/core/services/persistent_streaming_service.dart index e746139..3569428 100644 --- a/lib/core/services/persistent_streaming_service.dart +++ b/lib/core/services/persistent_streaming_service.dart @@ -234,7 +234,9 @@ class PersistentStreamingService with WidgetsBindingObserver { void unregisterStream(String streamId, {bool saveForRecovery = false}) { // If app is in background and stream is unregistering, it might be due to // network interruption - save state for recovery instead of just dropping it - if (_isInBackground && !saveForRecovery && _streamMetadata.containsKey(streamId)) { + if (_isInBackground && + !saveForRecovery && + _streamMetadata.containsKey(streamId)) { DebugLogger.stream( 'PersistentStreamingService: Stream $streamId interrupted in background, saving for recovery', ); diff --git a/lib/core/services/self_signed_certificate_manager.dart b/lib/core/services/self_signed_certificate_manager.dart new file mode 100644 index 0000000..6bdefbd --- /dev/null +++ b/lib/core/services/self_signed_certificate_manager.dart @@ -0,0 +1,23 @@ +import '../models/server_config.dart'; +import 'self_signed_certificate_manager_io.dart' + if (dart.library.html) 'self_signed_certificate_manager_stub.dart' + as platform; + +/// Coordinates opt-in trust for self-signed TLS certificates. +/// +/// On IO platforms we install an [HttpOverrides] that whitelists the servers +/// flagged in [ServerConfig.allowSelfSignedCertificates]. On web platforms the +/// helpers are no-ops because browsers manage TLS validation themselves. +class SelfSignedCertificateManager { + const SelfSignedCertificateManager._(); + + static const SelfSignedCertificateManager instance = + SelfSignedCertificateManager._(); + + void ensureInitialized() => platform.ensureInitialized(); + + void updateTrustedServers(Iterable configs) => + platform.updateTrustedServers(configs); + + void clearTrustedServers() => platform.clearTrustedServers(); +} diff --git a/lib/core/services/self_signed_certificate_manager_io.dart b/lib/core/services/self_signed_certificate_manager_io.dart new file mode 100644 index 0000000..9601f1a --- /dev/null +++ b/lib/core/services/self_signed_certificate_manager_io.dart @@ -0,0 +1,117 @@ +import 'dart:io'; + +import '../models/server_config.dart'; + +final _IoSelfSignedCertificateManager _manager = + _IoSelfSignedCertificateManager(); + +void ensureInitialized() => _manager.ensureInitialized(); + +void updateTrustedServers(Iterable configs) => + _manager.updateTrustedServers(configs); + +void clearTrustedServers() => _manager.clearTrustedServers(); + +class _IoSelfSignedCertificateManager { + _IoSelfSignedCertificateManager(); + + _ConduitHttpOverrides? _overrides; + + void ensureInitialized() { + if (_overrides != null) return; + + final overrides = _ConduitHttpOverrides(); + HttpOverrides.global = overrides; + _overrides = overrides; + } + + void updateTrustedServers(Iterable configs) { + ensureInitialized(); + _overrides?.updateTrustedServers(configs); + } + + void clearTrustedServers() { + _overrides?.clearTrustedServers(); + } +} + +class _ConduitHttpOverrides extends HttpOverrides { + final Set<_TrustedEndpoint> _trustedEndpoints = {}; + + void updateTrustedServers(Iterable configs) { + _trustedEndpoints + ..clear() + ..addAll( + configs + .where((config) => config.allowSelfSignedCertificates) + .map((config) => _TrustedEndpoint.fromUrl(config.url)) + .whereType<_TrustedEndpoint>(), + ); + } + + void clearTrustedServers() { + _trustedEndpoints.clear(); + } + + bool _shouldTrust(String host, int port) { + for (final endpoint in _trustedEndpoints) { + if (endpoint.matches(host, port)) { + return true; + } + } + return false; + } + + @override + HttpClient createHttpClient(SecurityContext? context) { + final client = super.createHttpClient(context); + client.badCertificateCallback = + (X509Certificate cert, String host, int port) => + _shouldTrust(host, port); + return client; + } +} + +class _TrustedEndpoint { + const _TrustedEndpoint({required this.host, this.port}); + + final String host; + final int? port; + + static _TrustedEndpoint? fromUrl(String url) { + final uri = _normalizeUrl(url); + if (uri == null || uri.host.isEmpty) { + return null; + } + final normalizedHost = uri.host.toLowerCase(); + final normalizedPort = uri.hasPort ? uri.port : null; + return _TrustedEndpoint(host: normalizedHost, port: normalizedPort); + } + + static Uri? _normalizeUrl(String value) { + if (value.trim().isEmpty) { + return null; + } + Uri? parsed = Uri.tryParse(value.trim()); + if (parsed == null) { + return null; + } + if (!parsed.hasScheme) { + parsed = + Uri.tryParse('https://${value.trim()}') ?? + Uri.tryParse('http://${value.trim()}'); + } + return parsed; + } + + bool matches(String otherHost, int otherPort) { + final normalizedHost = otherHost.toLowerCase(); + if (normalizedHost != host) { + return false; + } + if (port == null) { + return true; + } + return port == otherPort; + } +} diff --git a/lib/core/services/self_signed_certificate_manager_stub.dart b/lib/core/services/self_signed_certificate_manager_stub.dart new file mode 100644 index 0000000..4abbafd --- /dev/null +++ b/lib/core/services/self_signed_certificate_manager_stub.dart @@ -0,0 +1,7 @@ +import '../models/server_config.dart'; + +void ensureInitialized() {} + +void updateTrustedServers(Iterable configs) {} + +void clearTrustedServers() {} diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index 1eb84c7..1fc7668 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -107,16 +107,22 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ persistentController.add(data); }, onDone: () async { - DebugLogger.stream('Source stream onDone fired, hasReceivedData=$hasReceivedData'); + DebugLogger.stream( + 'Source stream onDone fired, hasReceivedData=$hasReceivedData', + ); // If stream closes immediately without data, it's likely due to backgrounding/network drop // Not a natural completion if (!hasReceivedData) { - DebugLogger.stream('Stream closed without data - likely interrupted, not completing'); + DebugLogger.stream( + 'Stream closed without data - likely interrupted, not completing', + ); // Check if app is backgrounding - if so, finish streaming with whatever we have await Future.delayed(const Duration(milliseconds: 300)); if (persistentService.isInBackground) { - DebugLogger.stream('App backgrounding during stream - finishing with current content'); + DebugLogger.stream( + 'App backgrounding during stream - finishing with current content', + ); finishStreaming(); } // Don't close the controller to prevent cascading completion handlers @@ -127,14 +133,20 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ await Future.delayed(const Duration(milliseconds: 500)); final isInBg = persistentService.isInBackground; - DebugLogger.stream('Stream onDone check: streamId=$streamId, isInBackground=$isInBg'); + DebugLogger.stream( + 'Stream onDone check: streamId=$streamId, isInBackground=$isInBg', + ); // Check if we're in background before closing if (!isInBg) { - DebugLogger.stream('Closing stream controller for $streamId (foreground completion)'); + DebugLogger.stream( + 'Closing stream controller for $streamId (foreground completion)', + ); persistentController.close(); } else { - DebugLogger.stream('Source stream completed in background for $streamId - keeping open for recovery'); + DebugLogger.stream( + 'Source stream completed in background for $streamId - keeping open for recovery', + ); // Finish streaming to save the content we have finishStreaming(); } @@ -417,7 +429,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ if (name is String && name.isNotEmpty) { final msgs = getMessages(); // Quick string check before expensive regex - final exists = (msgs.isNotEmpty) && + final exists = + (msgs.isNotEmpty) && msgs.last.content.contains('name="$name"'); if (!exists) { final status = @@ -519,7 +532,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ if (name is String && name.isNotEmpty) { final msgs = getMessages(); // Quick string check before expensive regex - final exists = (msgs.isNotEmpty) && + final exists = + (msgs.isNotEmpty) && msgs.last.content.contains('name="$name"'); if (!exists) { final status = @@ -549,7 +563,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ if (name is String && name.isNotEmpty) { final msgs = getMessages(); // Quick string check before expensive regex - final exists = (msgs.isNotEmpty) && + final exists = + (msgs.isNotEmpty) && msgs.last.content.contains('name="$name"'); if (!exists) { final status = diff --git a/lib/features/auth/views/server_connection_page.dart b/lib/features/auth/views/server_connection_page.dart index 2b8d034..7c929ae 100644 --- a/lib/features/auth/views/server_connection_page.dart +++ b/lib/features/auth/views/server_connection_page.dart @@ -36,6 +36,7 @@ class _ServerConnectionPageState extends ConsumerState { String? _connectionError; bool _isConnecting = false; bool _showAdvancedSettings = false; + bool _allowSelfSignedCertificates = false; @override void initState() { @@ -45,9 +46,11 @@ class _ServerConnectionPageState extends ConsumerState { Future _prefillFromState() async { final activeServer = await ref.read(activeServerProvider.future); - if (activeServer != null) { + if (!mounted || activeServer == null) return; + setState(() { _urlController.text = activeServer.url; - } + _allowSelfSignedCertificates = activeServer.allowSelfSignedCertificates; + }); } @override @@ -75,6 +78,7 @@ class _ServerConnectionPageState extends ConsumerState { url: url, customHeaders: Map.from(_customHeaders), isActive: true, + allowSelfSignedCertificates: _allowSelfSignedCertificates, ); final api = ApiService(serverConfig: tempConfig); @@ -536,6 +540,69 @@ class _ServerConnectionPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + margin: const EdgeInsets.only(bottom: Spacing.md), + decoration: BoxDecoration( + color: context.conduitTheme.surfaceContainer.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + border: Border.all( + color: context.conduitTheme.dividerColor.withValues(alpha: 0.4), + width: BorderWidth.thin, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Platform.isIOS + ? CupertinoIcons.lock_shield + : Icons.verified_user, + color: context.conduitTheme.iconSecondary, + size: IconSize.small, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of( + context, + )!.allowSelfSignedCertificates, + style: context.conduitTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + AppLocalizations.of( + context, + )!.allowSelfSignedCertificatesDescription, + style: context.conduitTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: Spacing.sm), + Switch.adaptive( + value: _allowSelfSignedCertificates, + onChanged: (value) { + setState(() { + _allowSelfSignedCertificates = value; + }); + }, + activeTrackColor: context.conduitTheme.buttonPrimary, + ), + ], + ), + ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/features/chat/services/voice_call_notification_service.dart b/lib/features/chat/services/voice_call_notification_service.dart index 5cbdb5f..b2620a4 100644 --- a/lib/features/chat/services/voice_call_notification_service.dart +++ b/lib/features/chat/services/voice_call_notification_service.dart @@ -17,8 +17,7 @@ class VoiceCallNotificationService { // Notification IDs and channels static const String _channelId = 'voice_call_channel'; static const String _channelName = 'Voice Call'; - static const String _channelDescription = - 'Ongoing voice call notifications'; + static const String _channelDescription = 'Ongoing voice call notifications'; static const int _notificationId = 2001; // Action IDs @@ -32,7 +31,9 @@ class VoiceCallNotificationService { Future initialize() async { if (_initialized) return; - const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); const iosSettings = DarwinInitializationSettings( requestAlertPermission: false, requestBadgePermission: false, @@ -72,7 +73,8 @@ class VoiceCallNotificationService { await _notifications .resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>() + AndroidFlutterLocalNotificationsPlugin + >() ?.createNotificationChannel(androidChannel); } @@ -190,8 +192,10 @@ class VoiceCallNotificationService { /// Check if notifications are enabled Future areNotificationsEnabled() async { if (Platform.isAndroid) { - final androidImpl = _notifications.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); + final androidImpl = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); return await androidImpl?.areNotificationsEnabled() ?? false; } else if (Platform.isIOS) { // iOS doesn't have a direct check, assume enabled if initialized @@ -203,13 +207,17 @@ class VoiceCallNotificationService { /// Request notification permissions Future requestPermissions() async { if (Platform.isAndroid) { - final androidImpl = _notifications.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); + final androidImpl = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); final granted = await androidImpl?.requestNotificationsPermission(); return granted ?? false; } else if (Platform.isIOS) { final iosImpl = _notifications - .resolvePlatformSpecificImplementation(); + .resolvePlatformSpecificImplementation< + IOSFlutterLocalNotificationsPlugin + >(); final granted = await iosImpl?.requestPermissions( alert: true, badge: false, diff --git a/lib/features/chat/views/voice_call_page.dart b/lib/features/chat/views/voice_call_page.dart index 054023b..1768ade 100644 --- a/lib/features/chat/views/voice_call_page.dart +++ b/lib/features/chat/views/voice_call_page.dart @@ -148,9 +148,8 @@ class _VoiceCallPageState extends ConsumerState icon: const Icon(CupertinoIcons.xmark), onPressed: () async { await _service?.stopCall(); - if (mounted) { - Navigator.of(context).pop(); - } + if (!context.mounted) return; + Navigator.of(context).pop(); }, ), ), diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 30b8e4b..1625ed3 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -1480,9 +1480,7 @@ class _ModernChatInputState extends ConsumerState ), child: Center( child: Icon( - Platform.isIOS - ? CupertinoIcons.waveform - : Icons.graphic_eq, + Platform.isIOS ? CupertinoIcons.waveform : Icons.graphic_eq, size: IconSize.large, color: enabledVoiceCall ? context.conduitTheme.buttonPrimaryText diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 79cf638..83e6956 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -259,6 +259,14 @@ "advancedSettings": "Erweiterte Einstellungen", "customHeaders": "Benutzerdefinierte Header", "customHeadersDescription": "Füge benutzerdefinierte HTTP-Header für Authentifizierung, API-Schlüssel oder spezielle Serveranforderungen hinzu.", + "allowSelfSignedCertificates": "Selbstsignierten Zertifikaten vertrauen", + "@allowSelfSignedCertificates": { + "description": "Schalterbeschriftung zum Zulassen selbstsignierter TLS-Zertifikate für den konfigurierten Server." + }, + "allowSelfSignedCertificatesDescription": "Akzeptiere das TLS-Zertifikat dieses Servers auch dann, wenn es selbstsigniert ist. Aktiviere diese Option nur für Server, denen du vertraust.", + "@allowSelfSignedCertificatesDescription": { + "description": "Hilfetext, der die Risiken beim Aktivieren des Schalters für selbstsignierte Zertifikate erklärt." + }, "headerNameEmpty": "Header-Name darf nicht leer sein", "headerNameTooLong": "Header-Name zu lang (max. 64 Zeichen)", "headerNameInvalidChars": "Ungültiger Header-Name. Verwende nur Buchstaben, Zahlen und diese Zeichen: !#$&-^_`|~", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2f93113..214e6a8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -550,6 +550,14 @@ "@customHeaders": {"description": "Section title for adding custom HTTP headers."}, "customHeadersDescription": "Add custom HTTP headers for authentication, API keys, or special server requirements.", "@customHeadersDescription": {"description": "Helper text explaining use-cases for custom headers."}, + "allowSelfSignedCertificates": "Trust self-signed certificates", + "@allowSelfSignedCertificates": { + "description": "Toggle label that allows trusting self-signed TLS certificates for the configured server." + }, + "allowSelfSignedCertificatesDescription": "Accept this server's TLS certificate even if it is self-signed. Enable only for servers you trust.", + "@allowSelfSignedCertificatesDescription": { + "description": "Helper text clarifying the risks of enabling the self-signed certificate toggle." + }, "headerNameEmpty": "Header name cannot be empty", "@headerNameEmpty": {"description": "Validation message for empty header name."}, "headerNameTooLong": "Header name too long (max 64 characters)", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7468ca9..c7290fe 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -259,6 +259,14 @@ "advancedSettings": "Paramètres avancés", "customHeaders": "En-têtes personnalisés", "customHeadersDescription": "Ajoutez des en-têtes HTTP personnalisés pour l'authentification, les clés API ou des exigences spécifiques du serveur.", + "allowSelfSignedCertificates": "Faire confiance aux certificats auto-signés", + "@allowSelfSignedCertificates": { + "description": "Libellé du commutateur permettant d'autoriser les certificats TLS auto-signés pour le serveur configuré." + }, + "allowSelfSignedCertificatesDescription": "Acceptez le certificat TLS de ce serveur même s'il est auto-signé. Activez cette option uniquement pour les serveurs auxquels vous faites confiance.", + "@allowSelfSignedCertificatesDescription": { + "description": "Texte d'aide expliquant les risques liés à l'activation de l'option de certificat auto-signé." + }, "headerNameEmpty": "Le nom de l'en-tête ne peut pas être vide", "headerNameTooLong": "Nom d'en-tête trop long (max 64 caractères)", "headerNameInvalidChars": "Nom d'en-tête invalide. Utilisez uniquement des lettres, des chiffres et ces symboles : !#$&-^_`|~", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 51bf6b7..37b77ef 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -259,6 +259,14 @@ "advancedSettings": "Impostazioni avanzate", "customHeaders": "Header personalizzati", "customHeadersDescription": "Aggiungi header HTTP personalizzati per autenticazione, chiavi API o requisiti speciali del server.", + "allowSelfSignedCertificates": "Considera attendibili i certificati autofirmati", + "@allowSelfSignedCertificates": { + "description": "Etichetta dell'interruttore che consente i certificati TLS autofirmati per il server configurato." + }, + "allowSelfSignedCertificatesDescription": "Accetta il certificato TLS di questo server anche se è autofirmato. Attiva questa opzione solo per server di cui ti fidi.", + "@allowSelfSignedCertificatesDescription": { + "description": "Testo di supporto che chiarisce i rischi dell'attivazione dell'opzione per certificati autofirmati." + }, "headerNameEmpty": "Il nome header non può essere vuoto", "headerNameTooLong": "Nome header troppo lungo (max 64 caratteri)", "headerNameInvalidChars": "Nome header non valido. Usa solo lettere, numeri e questi simboli: !#$&-^_`|~", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index becc466..8e8d9eb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1482,6 +1482,18 @@ abstract class AppLocalizations { /// **'Add custom HTTP headers for authentication, API keys, or special server requirements.'** String get customHeadersDescription; + /// Toggle label that allows trusting self-signed TLS certificates for the configured server. + /// + /// In en, this message translates to: + /// **'Trust self-signed certificates'** + String get allowSelfSignedCertificates; + + /// Helper text clarifying the risks of enabling the self-signed certificate toggle. + /// + /// In en, this message translates to: + /// **'Accept this server\'s TLS certificate even if it is self-signed. Enable only for servers you trust.'** + String get allowSelfSignedCertificatesDescription; + /// Validation message for empty header name. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index aa1370b..f0df1c1 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -760,6 +760,14 @@ class AppLocalizationsDe extends AppLocalizations { String get customHeadersDescription => 'Füge benutzerdefinierte HTTP-Header für Authentifizierung, API-Schlüssel oder spezielle Serveranforderungen hinzu.'; + @override + String get allowSelfSignedCertificates => + 'Selbstsignierten Zertifikaten vertrauen'; + + @override + String get allowSelfSignedCertificatesDescription => + 'Akzeptiere das TLS-Zertifikat dieses Servers auch dann, wenn es selbstsigniert ist. Aktiviere diese Option nur für Server, denen du vertraust.'; + @override String get headerNameEmpty => 'Header-Name darf nicht leer sein'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 02f032a..958905b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -754,6 +754,13 @@ class AppLocalizationsEn extends AppLocalizations { String get customHeadersDescription => 'Add custom HTTP headers for authentication, API keys, or special server requirements.'; + @override + String get allowSelfSignedCertificates => 'Trust self-signed certificates'; + + @override + String get allowSelfSignedCertificatesDescription => + 'Accept this server\'s TLS certificate even if it is self-signed. Enable only for servers you trust.'; + @override String get headerNameEmpty => 'Header name cannot be empty'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index da42ca8..fb8fe7b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -765,6 +765,14 @@ class AppLocalizationsFr extends AppLocalizations { String get customHeadersDescription => 'Ajoutez des en-têtes HTTP personnalisés pour l\'authentification, les clés API ou des exigences spécifiques du serveur.'; + @override + String get allowSelfSignedCertificates => + 'Faire confiance aux certificats auto-signés'; + + @override + String get allowSelfSignedCertificatesDescription => + 'Acceptez le certificat TLS de ce serveur même s\'il est auto-signé. Activez cette option uniquement pour les serveurs auxquels vous faites confiance.'; + @override String get headerNameEmpty => 'Le nom de l\'en-tête ne peut pas être vide'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index a8e9395..0ba48eb 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -756,6 +756,14 @@ class AppLocalizationsIt extends AppLocalizations { String get customHeadersDescription => 'Aggiungi header HTTP personalizzati per autenticazione, chiavi API o requisiti speciali del server.'; + @override + String get allowSelfSignedCertificates => + 'Considera attendibili i certificati autofirmati'; + + @override + String get allowSelfSignedCertificatesDescription => + 'Accetta il certificato TLS di questo server anche se è autofirmato. Attiva questa opzione solo per server di cui ti fidi.'; + @override String get headerNameEmpty => 'Il nome header non può essere vuoto'; diff --git a/lib/main.dart b/lib/main.dart index b249b74..247447b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'core/providers/app_providers.dart'; import 'core/persistence/hive_bootstrap.dart'; import 'core/persistence/persistence_migrator.dart'; import 'core/persistence/persistence_providers.dart'; +import 'core/services/self_signed_certificate_manager.dart'; import 'core/router/app_router.dart'; import 'shared/widgets/offline_indicator.dart'; import 'features/auth/providers/unified_auth_providers.dart'; @@ -29,6 +30,8 @@ void main() { () async { WidgetsFlutterBinding.ensureInitialized(); + SelfSignedCertificateManager.instance.ensureInitialized(); + // Global error handlers FlutterError.onError = (FlutterErrorDetails details) { DebugLogger.error( diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 42ec6cd..f17d80c 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -727,7 +727,9 @@ class _DetailsBlockSyntax extends md.BlockSyntax { } // Check for summary tag - final summaryMatch = RegExp(r'^(.*?)<\/summary>$').firstMatch(line); + final summaryMatch = RegExp( + r'^(.*?)<\/summary>$', + ).firstMatch(line); if (summaryMatch != null) { summary = summaryMatch.group(1) ?? ''; parser.advance(); diff --git a/lib/shared/widgets/markdown/markdown_preprocessor.dart b/lib/shared/widgets/markdown/markdown_preprocessor.dart index 84a168b..4fee307 100644 --- a/lib/shared/widgets/markdown/markdown_preprocessor.dart +++ b/lib/shared/widgets/markdown/markdown_preprocessor.dart @@ -13,10 +13,7 @@ class ConduitMarkdownPreprocessor { r'^[ \t]+```([^\n`]*)\s*$', multiLine: true, ); - static final _dedentCloseRegex = RegExp( - r'^[ \t]+```\s*$', - multiLine: true, - ); + static final _dedentCloseRegex = RegExp(r'^[ \t]+```\s*$', multiLine: true); static final _inlineClosingRegex = RegExp(r'([^\r\n`])```(?=\s*(?:\r?\n|$))'); static final _labelThenDashRegex = RegExp( r'^(\*\*[^\n*]+\*\*.*)\n(\s*-{3,}\s*$)', @@ -45,7 +42,10 @@ class ConduitMarkdownPreprocessor { // Dedent opening fences to avoid partial code-block detection when the // model indents fences by accident. - output = output.replaceAllMapped(_dedentOpenRegex, (match) => '```${match[1]}'); + output = output.replaceAllMapped( + _dedentOpenRegex, + (match) => '```${match[1]}', + ); // Dedent closing fences for the same reason as the opening fences. output = output.replaceAllMapped(_dedentCloseRegex, (_) => '```');