feat(transport): Improve socket transport mode selection and localization

This commit is contained in:
cogwheel0
2025-10-30 22:32:59 +05:30
parent f4561484f6
commit a00d64fc26
19 changed files with 441 additions and 50 deletions

View File

@@ -0,0 +1,53 @@
import 'package:flutter/foundation.dart';
/// Subset of the backend `/api/config` response the app cares about.
@immutable
class BackendConfig {
const BackendConfig({this.enableWebsocket});
/// Mirrors `features.enable_websocket` from OpenWebUI.
final bool? enableWebsocket;
/// Returns a copy with updated fields.
BackendConfig copyWith({bool? enableWebsocket}) {
return BackendConfig(
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
);
}
/// Whether the backend only allows WebSocket transport.
bool get websocketOnly => enableWebsocket == true;
/// Whether the backend only allows HTTP polling transport.
bool get pollingOnly => enableWebsocket == false;
/// Whether the backend permits choosing WebSocket-only mode.
bool get supportsWebsocketOnly => !pollingOnly;
/// Whether the backend permits choosing polling fallback.
bool get supportsPolling => !websocketOnly;
/// Returns the enforced transport mode derived from backend policy.
String? get enforcedTransportMode {
if (websocketOnly) return 'ws';
if (pollingOnly) return 'polling';
return null;
}
Map<String, dynamic> toJson() {
return <String, dynamic>{'enable_websocket': enableWebsocket};
}
static BackendConfig fromJson(Map<String, dynamic> json) {
bool? enableWebsocket;
final features = json['features'];
if (features is Map<String, dynamic>) {
final value = features['enable_websocket'];
if (value is bool) {
enableWebsocket = value;
}
}
return BackendConfig(enableWebsocket: enableWebsocket);
}
}

View File

@@ -15,6 +15,7 @@ import '../models/user.dart';
import '../models/model.dart';
import '../models/conversation.dart';
import '../models/chat_message.dart';
import '../models/backend_config.dart';
import '../models/folder.dart';
import '../models/user_settings.dart';
import '../models/file_info.dart';
@@ -172,6 +173,84 @@ final serverConnectionStateProvider = Provider<bool>((ref) {
);
});
final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
final api = ref.watch(apiServiceProvider);
if (api == null) {
return null;
}
final server = await ref.watch(activeServerProvider.future);
if (server == null) {
return null;
}
try {
final config = await api.getBackendConfig();
if (config != null) {
final forcedMode = config.enforcedTransportMode;
if (forcedMode != null) {
final settings = ref.read(appSettingsProvider);
if (settings.socketTransportMode != forcedMode) {
Future.microtask(() {
ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode(forcedMode);
});
}
}
}
return config;
} catch (_) {
return null;
}
});
class SocketTransportAvailability {
const SocketTransportAvailability({
required this.allowPolling,
required this.allowWebsocketOnly,
});
final bool allowPolling;
final bool allowWebsocketOnly;
}
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
ref,
) {
final backendConfigAsync = ref.watch(backendConfigProvider);
final config = backendConfigAsync.maybeWhen(
data: (value) => value,
orElse: () => null,
);
if (config == null) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
}
if (config.websocketOnly) {
return const SocketTransportAvailability(
allowPolling: false,
allowWebsocketOnly: true,
);
}
if (config.pollingOnly) {
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: false,
);
}
return const SocketTransportAvailability(
allowPolling: true,
allowWebsocketOnly: true,
);
});
// API Service provider with unified auth integration
final apiServiceProvider = Provider<ApiService?>((ref) {
// If reviewer mode is enabled, skip creating ApiService
@@ -242,6 +321,8 @@ class SocketServiceManager extends _$SocketServiceManager {
appSettingsProvider.select((settings) => settings.socketTransportMode),
);
final websocketOnly = transportMode == 'ws';
final transportAvailability = ref.watch(socketTransportOptionsProvider);
final allowWebsocketUpgrade = transportAvailability.allowWebsocketOnly;
// Don't watch authTokenProvider3 here to avoid rebuilding on token changes
// Token updates are handled via the subscription below
@@ -250,13 +331,15 @@ class SocketServiceManager extends _$SocketServiceManager {
final requiresNewService =
_service == null ||
_service!.serverConfig.id != server.id ||
_service!.websocketOnly != websocketOnly;
_service!.websocketOnly != websocketOnly ||
_service!.allowWebsocketUpgrade != allowWebsocketUpgrade;
if (requiresNewService) {
_disposeService();
_service = SocketService(
serverConfig: server,
authToken: token,
websocketOnly: websocketOnly,
allowWebsocketUpgrade: allowWebsocketUpgrade,
);
_scheduleConnect(_service!);
} else {

View File

@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
// import 'package:http_parser/http_parser.dart';
// Removed legacy websocket/socket.io imports
import 'package:uuid/uuid.dart';
import '../models/backend_config.dart';
import '../models/server_config.dart';
import '../models/user.dart';
import '../models/model.dart';
@@ -253,6 +254,44 @@ class ApiService {
return result;
}
Future<BackendConfig?> getBackendConfig() async {
try {
final response = await _dio.get('/api/config');
final data = response.data;
Map<String, dynamic>? jsonMap;
if (data is Map<String, dynamic>) {
jsonMap = data;
} else if (data is String && data.isNotEmpty) {
final decoded = json.decode(data);
if (decoded is Map<String, dynamic>) {
jsonMap = decoded;
}
}
if (jsonMap == null) {
return null;
}
return BackendConfig.fromJson(jsonMap);
} on DioException catch (e, stackTrace) {
_traceApi('Backend config request failed: $e');
DebugLogger.error(
'backend-config-error',
scope: 'api/config',
error: e,
stackTrace: stackTrace,
);
rethrow;
} catch (e, stackTrace) {
_traceApi('Backend config decode error: $e');
DebugLogger.error(
'backend-config-decode',
scope: 'api/config',
error: e,
stackTrace: stackTrace,
);
rethrow;
}
}
// Authentication
Future<Map<String, dynamic>> login(String username, String password) async {
try {

View File

@@ -26,7 +26,7 @@ class SettingsService {
static const String _voiceAutoSendKey = PreferenceKeys.voiceAutoSendFinal;
// Realtime transport preference
static const String _socketTransportModeKey =
PreferenceKeys.socketTransportMode; // 'auto' or 'ws'
PreferenceKeys.socketTransportMode; // 'polling' or 'ws'
// Quick pill visibility selections (max 2)
static const String _quickPillsKey = PreferenceKeys
.quickPills; // StringList of identifiers e.g. ['web','image','tools']
@@ -256,15 +256,27 @@ class SettingsService {
return _preferencesBox().put(_voiceAutoSendKey, value);
}
/// Transport mode: 'auto' (polling+websocket) or 'ws' (websocket only)
/// Transport mode: 'polling' (HTTP polling + WebSocket upgrade) or 'ws'
static Future<String> getSocketTransportMode() {
final value = _preferencesBox().get(_socketTransportModeKey) as String?;
return Future.value(value ?? 'ws');
final raw = _preferencesBox().get(_socketTransportModeKey) as String?;
if (raw == null) {
return Future.value('ws');
}
if (raw == 'auto') {
return Future.value('polling');
}
if (raw != 'polling' && raw != 'ws') {
return Future.value('ws');
}
return Future.value(raw);
}
static Future<void> setSocketTransportMode(String mode) {
if (mode != 'auto' && mode != 'ws') {
mode = 'auto';
if (mode == 'auto') {
mode = 'polling';
}
if (mode != 'polling' && mode != 'ws') {
mode = 'polling';
}
return _preferencesBox().put(_socketTransportModeKey, mode);
}
@@ -344,7 +356,7 @@ class AppSettings {
final String? voiceLocaleId;
final bool voiceHoldToTalk;
final bool voiceAutoSendFinal;
final String socketTransportMode; // 'auto' or 'ws'
final String socketTransportMode; // 'polling' or 'ws'
final List<String> quickPills; // e.g., ['web','image']
final bool sendOnEnter;
final String? ttsVoice;
@@ -566,8 +578,17 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
}
Future<void> setSocketTransportMode(String mode) async {
state = state.copyWith(socketTransportMode: mode);
await SettingsService.setSocketTransportMode(mode);
var sanitized = mode;
if (sanitized == 'auto') {
sanitized = 'polling';
}
if (sanitized != 'polling' && sanitized != 'ws') {
sanitized = 'polling';
}
if (state.socketTransportMode != sanitized) {
state = state.copyWith(socketTransportMode: sanitized);
}
await SettingsService.setSocketTransportMode(sanitized);
}
Future<void> setQuickPills(List<String> pills) async {

View File

@@ -22,6 +22,7 @@ typedef SocketChannelEventHandler =
class SocketService with WidgetsBindingObserver {
final ServerConfig serverConfig;
final bool websocketOnly;
final bool allowWebsocketUpgrade;
io.Socket? _socket;
String? _authToken;
bool _isAppForeground = true;
@@ -34,6 +35,7 @@ class SocketService with WidgetsBindingObserver {
required this.serverConfig,
String? authToken,
this.websocketOnly = false,
this.allowWebsocketUpgrade = true,
}) : _authToken = authToken {
final binding = WidgetsBinding.instance;
final lifecycle = binding.lifecycleState;
@@ -72,11 +74,18 @@ class SocketService with WidgetsBindingObserver {
} catch (_) {}
final path = '/ws/socket.io';
final usePollingOnly = !websocketOnly && !allowWebsocketUpgrade;
final transports = websocketOnly
? const ['websocket']
: usePollingOnly
? const ['polling']
: const ['polling', 'websocket'];
final builder = io.OptionBuilder()
// Transport selection - WebSocket only, no polling fallback
.setTransports(['websocket'])
.setRememberUpgrade(false)
.setUpgrade(false)
// Transport selection switches between WebSocket-only and polling fallback
.setTransports(transports)
.setRememberUpgrade(!websocketOnly && allowWebsocketUpgrade)
.setUpgrade(!websocketOnly && allowWebsocketUpgrade)
// Tune reconnect/backoff and timeouts
.setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts
.setReconnectionDelay(1000)

View File

@@ -392,6 +392,18 @@ class AppCustomizationPage extends ConsumerWidget {
) {
final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
final transportAvailability = ref.watch(socketTransportOptionsProvider);
var activeTransportMode = settings.socketTransportMode;
if (!transportAvailability.allowPolling &&
activeTransportMode == 'polling') {
activeTransportMode = 'ws';
} else if (!transportAvailability.allowWebsocketOnly &&
activeTransportMode == 'ws') {
activeTransportMode = 'polling';
}
final transportLabel = activeTransportMode == 'polling'
? l10n.transportModePolling
: l10n.transportModeWs;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -402,6 +414,38 @@ class AppCustomizationPage extends ConsumerWidget {
TextStyle(color: theme.sidebarForeground, fontSize: 18),
),
const SizedBox(height: Spacing.sm),
_CustomizationTile(
leading: _buildIconBadge(
context,
UiUtils.platformIcon(
ios: CupertinoIcons.arrow_2_circlepath,
android: Icons.sync,
),
color: theme.buttonPrimary,
),
title: l10n.transportMode,
subtitle: transportLabel,
trailing:
transportAvailability.allowPolling &&
transportAvailability.allowWebsocketOnly
? _buildValueBadge(context, transportLabel)
: null,
onTap:
transportAvailability.allowPolling &&
transportAvailability.allowWebsocketOnly
? () => _showTransportModeSheet(
context,
ref,
settings,
allowPolling: transportAvailability.allowPolling,
allowWebsocketOnly: transportAvailability.allowWebsocketOnly,
)
: null,
showChevron:
transportAvailability.allowPolling &&
transportAvailability.allowWebsocketOnly,
),
const SizedBox(height: Spacing.sm),
_CustomizationTile(
leading: _buildIconBadge(
context,
@@ -1217,6 +1261,148 @@ class AppCustomizationPage extends ConsumerWidget {
}
}
Future<void> _showTransportModeSheet(
BuildContext context,
WidgetRef ref,
AppSettings settings, {
required bool allowPolling,
required bool allowWebsocketOnly,
}) async {
final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context)!;
var current = settings.socketTransportMode;
final options = <({String value, String title, String subtitle})>[];
if (allowPolling) {
options.add((
value: 'polling',
title: l10n.transportModePolling,
subtitle: l10n.transportModePollingInfo,
));
}
if (allowWebsocketOnly) {
options.add((
value: 'ws',
title: l10n.transportModeWs,
subtitle: l10n.transportModeWsInfo,
));
}
if (options.isEmpty) {
return;
}
if (!options.any((option) => option.value == current)) {
current = options.first.value;
}
await showModalBottomSheet<void>(
context: context,
backgroundColor: theme.sidebarBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.modal),
),
),
builder: (sheetContext) {
return SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.lg,
vertical: Spacing.md,
),
child: Row(
children: [
Expanded(
child: Text(
l10n.transportMode,
style:
theme.headingSmall?.copyWith(
color: theme.sidebarForeground,
) ??
TextStyle(
color: theme.sidebarForeground,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: Icon(Icons.close, color: theme.iconPrimary),
onPressed: () => Navigator.of(sheetContext).pop(),
),
],
),
),
const Divider(height: 1),
for (var i = 0; i < options.length; i++) ...[
() {
final option = options[i];
final selected = current == option.value;
return ListTile(
leading: Icon(
selected ? Icons.check_circle : Icons.circle_outlined,
color: selected
? theme.buttonPrimary
: theme.iconSecondary,
),
title: Text(option.title),
subtitle: Text(option.subtitle),
onTap: () {
if (!selected) {
ref
.read(appSettingsProvider.notifier)
.setSocketTransportMode(option.value);
}
Navigator.of(sheetContext).pop();
},
);
}(),
if (i != options.length - 1) const Divider(height: 1),
],
const SizedBox(height: Spacing.lg),
],
),
);
},
);
}
Widget _buildValueBadge(BuildContext context, String label) {
final theme = context.conduitTheme;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.md,
vertical: Spacing.xs,
),
decoration: BoxDecoration(
color: theme.buttonPrimary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: theme.buttonPrimary.withValues(alpha: 0.25),
width: BorderWidth.thin,
),
),
child: Text(
label,
style:
theme.bodySmall?.copyWith(
color: theme.buttonPrimary,
fontWeight: FontWeight.w600,
) ??
TextStyle(
color: theme.buttonPrimary,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
Widget _buildIconBadge(
BuildContext context,
IconData icon, {

View File

@@ -334,9 +334,9 @@
"transportMode": "Transportmodus",
"transportModeDescription": "Wähle, wie die App für Echtzeit-Updates verbindet.",
"mode": "Modus",
"transportModeAuto": "Auto (Polling + WebSocket)",
"transportModePolling": "Polling-Fallback",
"transportModeWs": "Nur WebSocket",
"transportModeAutoInfo": "Robuster in restriktiven Netzwerken. Wechselt nach Möglichkeit zu WebSocket.",
"transportModePollingInfo": "Fällt auf HTTP-Polling zurück, wenn WebSockets blockiert sind. Wechselt nach Möglichkeit zu WebSocket.",
"transportModeWsInfo": "Geringerer Overhead, kann jedoch hinter strikten Proxys/Firewalls fehlschlagen.",
"websocketConnectionError": "Echtzeit-Verbindung konnte nicht hergestellt werden. Bitte überprüfen Sie Ihr Netzwerk und die Serverkonfiguration.",
"websocketReconnectFailed": "Echtzeit-Verbindung fehlgeschlagen. Streaming funktioniert möglicherweise nicht ordnungsgemäß."

View File

@@ -694,12 +694,12 @@
"@transportModeDescription": {"description": "Helper text explaining the transport setting."},
"mode": "Mode",
"@mode": {"description": "Form field label for transport mode dropdown."},
"transportModeAuto": "Auto (Polling + WebSocket)",
"@transportModeAuto": {"description": "Dropdown option label for automatic transport selection."},
"transportModePolling": "Polling fallback",
"@transportModePolling": {"description": "Dropdown option label for HTTP polling fallback transport."},
"transportModeWs": "WebSocket only",
"@transportModeWs": {"description": "Dropdown option label for WebSocket-only transport."},
"transportModeAutoInfo": "More robust on restrictive networks. Upgrades to WebSocket when possible.",
"@transportModeAutoInfo": {"description": "Footnote text for the Auto transport mode."},
"transportModePollingInfo": "Falls back to HTTP polling when WebSocket is blocked. Upgrades to WebSocket when possible.",
"@transportModePollingInfo": {"description": "Footnote text for the polling fallback transport mode."},
"transportModeWsInfo": "Lower overhead, but may fail behind strict proxies/firewalls.",
"@transportModeWsInfo": {"description": "Footnote text for the WebSocket-only transport mode."},
"websocketConnectionError": "Unable to establish real-time connection. Please check your network and server configuration.",

View File

@@ -327,9 +327,9 @@
"transportMode": "Modo de transporte",
"transportModeDescription": "Elige cómo se conecta la aplicación para actualizaciones en tiempo real.",
"mode": "Modo",
"transportModeAuto": "Automático (Polling + WebSocket)",
"transportModePolling": "Polling de respaldo",
"transportModeWs": "Solo WebSocket",
"transportModeAutoInfo": "Más robusto en redes restrictivas. Se actualiza a WebSocket cuando es posible.",
"transportModePollingInfo": "Recurrirá a HTTP polling si WebSocket está bloqueado. Se actualiza a WebSocket cuando sea posible.",
"transportModeWsInfo": "Menor sobrecarga, pero puede fallar detrás de proxies/firewalls estrictos.",
"websocketConnectionError": "No se puede establecer la conexión en tiempo real. Por favor, verifica tu red y la configuración del servidor.",
"websocketReconnectFailed": "Fallo en la conexión en tiempo real. El streaming podría no funcionar correctamente."

View File

@@ -334,9 +334,9 @@
"transportMode": "Mode de transport",
"transportModeDescription": "Choisissez comment l'app se connecte pour les mises à jour en temps réel.",
"mode": "Mode",
"transportModeAuto": "Auto (Polling + WebSocket)",
"transportModePolling": "Polling de secours",
"transportModeWs": "WebSocket uniquement",
"transportModeAutoInfo": "Plus robuste sur les réseaux restrictifs. Passe à WebSocket lorsque possible.",
"transportModePollingInfo": "Bascule sur HTTP polling lorsque WebSocket est bloqué. Repasse à WebSocket dès que possible.",
"transportModeWsInfo": "Moins de surcharge, mais peut échouer derrière des proxys/firewalls stricts.",
"websocketConnectionError": "Impossible d'établir une connexion en temps réel. Veuillez vérifier votre réseau et la configuration du serveur.",
"websocketReconnectFailed": "Échec de la connexion en temps réel. Le streaming pourrait ne pas fonctionner correctement."

View File

@@ -334,9 +334,9 @@
"transportMode": "Modalità di trasporto",
"transportModeDescription": "Scegli come l'app si connette per gli aggiornamenti in tempo reale.",
"mode": "Modalità",
"transportModeAuto": "Auto (Polling + WebSocket)",
"transportModePolling": "Polling di fallback",
"transportModeWs": "Solo WebSocket",
"transportModeAutoInfo": "Più robusto nelle reti restrittive. Passa a WebSocket quando possibile.",
"transportModePollingInfo": "Quando WebSocket è bloccato passa a HTTP polling. Torna a WebSocket appena possibile.",
"transportModeWsInfo": "Minore overhead, ma può fallire dietro proxy/firewall restrittivi.",
"websocketConnectionError": "Impossibile stabilire una connessione in tempo reale. Si prega di controllare la rete e la configurazione del server.",
"websocketReconnectFailed": "Connessione in tempo reale fallita. Lo streaming potrebbe non funzionare correttamente."

View File

@@ -1826,11 +1826,11 @@ abstract class AppLocalizations {
/// **'Mode'**
String get mode;
/// Dropdown option label for automatic transport selection.
/// Dropdown option label for HTTP polling fallback transport.
///
/// In en, this message translates to:
/// **'Auto (Polling + WebSocket)'**
String get transportModeAuto;
/// **'Polling fallback'**
String get transportModePolling;
/// Dropdown option label for WebSocket-only transport.
///
@@ -1838,11 +1838,11 @@ abstract class AppLocalizations {
/// **'WebSocket only'**
String get transportModeWs;
/// Footnote text for the Auto transport mode.
/// Footnote text for the polling fallback transport mode.
///
/// In en, this message translates to:
/// **'More robust on restrictive networks. Upgrades to WebSocket when possible.'**
String get transportModeAutoInfo;
/// **'Falls back to HTTP polling when WebSocket is blocked. Upgrades to WebSocket when possible.'**
String get transportModePollingInfo;
/// Footnote text for the WebSocket-only transport mode.
///

View File

@@ -953,14 +953,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get mode => 'Modus';
@override
String get transportModeAuto => 'Auto (Polling + WebSocket)';
String get transportModePolling => 'Polling-Fallback';
@override
String get transportModeWs => 'Nur WebSocket';
@override
String get transportModeAutoInfo =>
'Robuster in restriktiven Netzwerken. Wechselt nach Möglichkeit zu WebSocket.';
String get transportModePollingInfo =>
'Fällt auf HTTP-Polling zurück, wenn WebSockets blockiert sind. Wechselt nach Möglichkeit zu WebSocket.';
@override
String get transportModeWsInfo =>

View File

@@ -945,14 +945,14 @@ class AppLocalizationsEn extends AppLocalizations {
String get mode => 'Mode';
@override
String get transportModeAuto => 'Auto (Polling + WebSocket)';
String get transportModePolling => 'Polling fallback';
@override
String get transportModeWs => 'WebSocket only';
@override
String get transportModeAutoInfo =>
'More robust on restrictive networks. Upgrades to WebSocket when possible.';
String get transportModePollingInfo =>
'Falls back to HTTP polling when WebSocket is blocked. Upgrades to WebSocket when possible.';
@override
String get transportModeWsInfo =>

View File

@@ -959,14 +959,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get mode => 'Mode';
@override
String get transportModeAuto => 'Auto (Polling + WebSocket)';
String get transportModePolling => 'Polling de secours';
@override
String get transportModeWs => 'WebSocket uniquement';
@override
String get transportModeAutoInfo =>
'Plus robuste sur les réseaux restrictifs. Passe à WebSocket lorsque possible.';
String get transportModePollingInfo =>
'Bascule sur HTTP polling lorsque WebSocket est bloqué. Repasse à WebSocket dès que possible.';
@override
String get transportModeWsInfo =>

View File

@@ -948,14 +948,14 @@ class AppLocalizationsIt extends AppLocalizations {
String get mode => 'Modalità';
@override
String get transportModeAuto => 'Auto (Polling + WebSocket)';
String get transportModePolling => 'Polling di fallback';
@override
String get transportModeWs => 'Solo WebSocket';
@override
String get transportModeAutoInfo =>
'Più robusto nelle reti restrittive. Passa a WebSocket quando possibile.';
String get transportModePollingInfo =>
'Quando WebSocket è bloccato passa a HTTP polling. Torna a WebSocket appena possibile.';
@override
String get transportModeWsInfo =>

View File

@@ -327,9 +327,9 @@
"transportMode": "Transportmodus",
"transportModeDescription": "Kies hoe de app verbindt voor realtime updates.",
"mode": "Modus",
"transportModeAuto": "Automatisch (Polling + WebSocket)",
"transportModePolling": "Polling-fallback",
"transportModeWs": "Alleen WebSocket",
"transportModeAutoInfo": "Robuuster op beperkende netwerken. Upgrade naar WebSocket indien mogelijk.",
"transportModePollingInfo": "Valt terug op HTTP-polling wanneer WebSocket geblokkeerd is. Upgrade naar WebSocket zodra dat kan.",
"transportModeWsInfo": "Lagere overhead, maar kan mislukken achter strikte proxies/firewalls.",
"websocketConnectionError": "Kan geen realtime verbinding maken. Controleer uw netwerk en serverconfiguratie.",
"websocketReconnectFailed": "Realtime verbinding mislukt. Streaming werkt mogelijk niet goed."

View File

@@ -327,9 +327,9 @@
"transportMode": "Режим транспорта",
"transportModeDescription": "Выберите, как приложение подключается для обновлений в реальном времени.",
"mode": "Режим",
"transportModeAuto": "Авто (опрос + WebSocket)",
"transportModePolling": "Опрос (резерв)",
"transportModeWs": "Только WebSocket",
"transportModeAutoInfo": "Более надежен в ограничительных сетях. Переходит на WebSocket, когда это возможно.",
"transportModePollingInfo": "Переходит на HTTP-опрос, если WebSocket заблокирован. Возвращается к WebSocket, когда это возможно.",
"transportModeWsInfo": "Меньше накладных расходов, но может не работать за строгими прокси/брандмауэрами.",
"websocketConnectionError": "Не удалось установить соединение в реальном времени. Пожалуйста, проверьте сеть и конфигурацию сервера.",
"websocketReconnectFailed": "Сбой соединения в реальном времени. Потоковая передача может работать неправильно."

View File

@@ -327,9 +327,9 @@
"transportMode": "传输模式",
"transportModeDescription": "选择应用如何连接以进行实时更新。",
"mode": "模式",
"transportModeAuto": "自动(轮询 + WebSocket",
"transportModePolling": "轮询回退",
"transportModeWs": "仅 WebSocket",
"transportModeAutoInfo": "在限制性网络上更稳健。在可能的情况下升级到 WebSocket。",
"transportModePollingInfo": "当 WebSocket 被阻止时改用 HTTP 轮询,在条件允许时切换回 WebSocket。",
"transportModeWsInfo": "开销较低,但可能在严格的代理/防火墙后失败。",
"websocketConnectionError": "无法建立实时连接。请检查您的网络和服务器配置。",
"websocketReconnectFailed": "实时连接失败。流式传输可能无法正常工作。"