diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 444d5572..1fab2dfd 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -123,6 +123,7 @@ class AppController extends ChangeNotifier { List get secretReferences => _settingsController.buildSecretReferences(); List get secretAuditTrail => _settingsController.auditTrail; + List get runtimeLogs => _runtime.logs; List get chatMessages { final items = List.from(_chatController.messages); @@ -467,6 +468,10 @@ class AppController extends ChangeNotifier { return _settingsController.testVaultConnection(); } + void clearRuntimeLogs() { + _runtime.clearLogs(); + } + Future validateApisixYaml(ApisixYamlProfile profile) { return _settingsController.validateApisixYaml(profile); } diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 8c42bd65..1d9c8e87 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -25,6 +25,7 @@ class _SettingsPageState extends State { late final TextEditingController _apisixYamlController; late final TextEditingController _vaultTokenController; late final TextEditingController _ollamaApiKeyController; + late final TextEditingController _runtimeLogFilterController; @override void initState() { @@ -34,6 +35,7 @@ class _SettingsPageState extends State { ); _vaultTokenController = TextEditingController(); _ollamaApiKeyController = TextEditingController(); + _runtimeLogFilterController = TextEditingController(); } @override @@ -41,6 +43,7 @@ class _SettingsPageState extends State { _apisixYamlController.dispose(); _vaultTokenController.dispose(); _ollamaApiKeyController.dispose(); + _runtimeLogFilterController.dispose(); super.dispose(); } @@ -673,6 +676,11 @@ class _SettingsPageState extends State { BuildContext context, AppController controller, ) { + final runtimeLogs = controller.runtimeLogs + .where(_matchesRuntimeLogFilter) + .toList(growable: false) + .reversed + .toList(growable: false); return [ SurfaceCard( child: Column( @@ -697,6 +705,16 @@ class _SettingsPageState extends State { label: appText('代理', 'Agent'), value: controller.activeAgentName, ), + _InfoRow( + label: appText('认证模式', 'Auth Mode'), + value: + controller.connection.connectAuthMode ?? + appText('未发起', 'Not attempted'), + ), + _InfoRow( + label: appText('认证诊断', 'Auth Diagnostics'), + value: controller.connection.connectAuthSummary, + ), _InfoRow( label: appText('健康负载', 'Health Payload'), value: controller.connection.healthPayload == null @@ -713,6 +731,93 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 16), + SurfaceCard( + key: const ValueKey('runtime-log-card'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('运行日志', 'Runtime Logs'), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + appText( + '只记录本机运行期的连接、鉴权、配对和 socket 诊断,不写入密钥明文。', + 'Shows local runtime diagnostics for connection, auth, pairing, and socket events without logging secret values.', + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(width: 12), + OutlinedButton( + onPressed: runtimeLogs.isEmpty + ? null + : () => controller.clearRuntimeLogs(), + child: Text(appText('清空', 'Clear')), + ), + ], + ), + const SizedBox(height: 16), + TextField( + key: const ValueKey('runtime-log-filter'), + controller: _runtimeLogFilterController, + decoration: InputDecoration( + labelText: appText('筛选日志', 'Filter Logs'), + hintText: appText( + '按级别、分类或关键字过滤', + 'Filter by level, category, or keyword', + ), + prefixIcon: const Icon(Icons.manage_search_rounded), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 16), + if (runtimeLogs.isEmpty) + Text( + appText('当前没有运行日志。', 'No runtime logs yet.'), + style: Theme.of(context).textTheme.bodyMedium, + ) + else + Container( + constraints: const BoxConstraints(maxHeight: 320), + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: SelectionArea( + child: ListView.separated( + itemCount: runtimeLogs.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final entry = runtimeLogs[index]; + return SelectableText( + entry.line, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ); + }, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -822,6 +927,16 @@ class _SettingsPageState extends State { return controller.saveSettings(snapshot); } + bool _matchesRuntimeLogFilter(RuntimeLogEntry entry) { + final query = _runtimeLogFilterController.text.trim().toLowerCase(); + if (query.isEmpty) { + return true; + } + final haystack = '${entry.level} ${entry.category} ${entry.message}' + .toLowerCase(); + return haystack.contains(query); + } + Widget _buildDeviceSecurityCard( BuildContext context, AppController controller, diff --git a/lib/runtime/gateway_runtime.dart b/lib/runtime/gateway_runtime.dart index f70c8bce..ff4c97bf 100644 --- a/lib/runtime/gateway_runtime.dart +++ b/lib/runtime/gateway_runtime.dart @@ -60,12 +60,14 @@ class GatewayRuntime extends ChangeNotifier { StreamController.broadcast(); final Map> _pending = >{}; + final List _logs = []; IOWebSocketChannel? _channel; StreamSubscription? _socketSubscription; Timer? _reconnectTimer; GatewayConnectionProfile? _desiredProfile; bool _manualDisconnect = false; + bool _suppressReconnect = false; int _requestCounter = 0; GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial( @@ -88,8 +90,26 @@ class GatewayRuntime extends ChangeNotifier { RuntimePackageInfo get packageInfo => _packageInfo; RuntimeDeviceInfo get deviceInfo => _deviceInfo; Stream get events => _events.stream; + List get logs => List.unmodifiable(_logs); bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + void clearLogs() { + if (_logs.isEmpty) { + return; + } + _logs.clear(); + notifyListeners(); + } + + @visibleForTesting + void addRuntimeLogForTest({ + required String level, + required String category, + required String message, + }) { + _appendLog(level, category, message); + } + Future initialize() async { await _store.initialize(); _packageInfo = await _loadPackageInfo(); @@ -104,6 +124,7 @@ class GatewayRuntime extends ChangeNotifier { }) async { _desiredProfile = profile; _manualDisconnect = false; + _suppressReconnect = false; await _closeSocket(); final endpoint = _resolveEndpoint(profile); @@ -112,11 +133,25 @@ class GatewayRuntime extends ChangeNotifier { final storedPassword = (await _store.loadGatewayPassword())?.trim() ?? ''; final explicitToken = authTokenOverride.trim(); final explicitPassword = authPasswordOverride.trim(); + final sharedTokenSource = explicitToken.isNotEmpty + ? 'shared:form' + : storedToken.isNotEmpty + ? 'shared:store' + : (setupPayload?.token.trim().isNotEmpty ?? false) + ? 'shared:setup-code' + : null; final sharedToken = explicitToken.isNotEmpty ? explicitToken : storedToken.isNotEmpty ? storedToken : (setupPayload?.token.trim() ?? ''); + final passwordSource = explicitPassword.isNotEmpty + ? 'password:form' + : storedPassword.isNotEmpty + ? 'password:store' + : (setupPayload?.password.trim().isNotEmpty ?? false) + ? 'password:setup-code' + : null; final password = explicitPassword.isNotEmpty ? explicitPassword : storedPassword.isNotEmpty @@ -130,25 +165,66 @@ class GatewayRuntime extends ChangeNotifier { ))?.trim() ?? ''; final explicitDeviceToken = ''; + final deviceTokenSource = explicitDeviceToken.isNotEmpty + ? 'device:form' + : sharedToken.isEmpty && storedDeviceToken.isNotEmpty + ? 'device:store' + : null; final deviceToken = explicitDeviceToken.isNotEmpty ? explicitDeviceToken : sharedToken.isEmpty ? storedDeviceToken : ''; final authToken = sharedToken.isNotEmpty ? sharedToken : deviceToken; + final connectAuthMode = sharedToken.isNotEmpty + ? 'shared-token' + : deviceToken.isNotEmpty + ? 'device-token' + : password.isNotEmpty + ? 'password' + : 'none'; + final connectAuthFields = [ + if (authToken.isNotEmpty) 'token', + if (deviceToken.isNotEmpty) 'deviceToken', + if (password.isNotEmpty) 'password', + ]; + final connectAuthSources = [ + ...?sharedTokenSource == null ? null : [sharedTokenSource], + ...?deviceTokenSource == null ? null : [deviceTokenSource], + ...?passwordSource == null ? null : [passwordSource], + ]; + final connectAuthSummary = _connectAuthSummary( + mode: connectAuthMode, + fields: connectAuthFields, + sources: connectAuthSources, + ); if (endpoint == null) { + _appendLog( + 'warn', + 'connect', + 'missing endpoint | auth: $connectAuthSummary', + ); _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode) .copyWith( statusText: 'Missing gateway endpoint', lastError: 'Configure setup code or manual host / port first.', lastErrorCode: 'MISSING_ENDPOINT', deviceId: identity.deviceId, + connectAuthMode: connectAuthMode, + connectAuthFields: connectAuthFields, + connectAuthSources: connectAuthSources, ); notifyListeners(); return; } + _appendLog( + 'info', + 'connect', + 'attempt ${endpoint.$1}:${endpoint.$2} tls:${endpoint.$3} | auth: $connectAuthSummary', + ); + _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( status: RuntimeConnectionStatus.connecting, statusText: 'Connecting…', @@ -156,6 +232,9 @@ class GatewayRuntime extends ChangeNotifier { deviceId: identity.deviceId, authRole: 'operator', authScopes: kDefaultOperatorConnectScopes, + connectAuthMode: connectAuthMode, + connectAuthFields: connectAuthFields, + connectAuthSources: connectAuthSources, hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty, hasDeviceToken: deviceToken.isNotEmpty, clearLastError: true, @@ -215,7 +294,14 @@ class GatewayRuntime extends ChangeNotifier { role: stringValue(auth['role']) ?? 'operator', token: returnedDeviceToken, ); + _appendLog( + 'info', + 'auth', + 'stored device token for role ${stringValue(auth['role']) ?? 'operator'}', + ); } + final negotiatedRole = stringValue(auth['role']) ?? 'operator'; + final negotiatedScopes = stringList(auth['scopes']); _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.connected, statusText: 'Connected', @@ -224,8 +310,11 @@ class GatewayRuntime extends ChangeNotifier { mainSessionKey: stringValue(sessionDefaults['mainSessionKey']) ?? 'main', lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch, - authRole: stringValue(auth['role']) ?? 'operator', - authScopes: stringList(auth['scopes']), + authRole: negotiatedRole, + authScopes: negotiatedScopes, + connectAuthMode: connectAuthMode, + connectAuthFields: connectAuthFields, + connectAuthSources: connectAuthSources, hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty, hasDeviceToken: (returnedDeviceToken != null && returnedDeviceToken.isNotEmpty) || @@ -234,6 +323,11 @@ class GatewayRuntime extends ChangeNotifier { clearLastErrorCode: true, clearLastErrorDetailCode: true, ); + _appendLog( + 'info', + 'connect', + 'connected ${endpoint.$1}:${endpoint.$2} | role: $negotiatedRole | scopes: ${negotiatedScopes.length}', + ); notifyListeners(); } catch (error) { final runtimeError = error is GatewayRuntimeException ? error : null; @@ -245,18 +339,39 @@ class GatewayRuntime extends ChangeNotifier { role: 'operator', ); } + if (!_shouldAutoReconnect(runtimeError)) { + _suppressReconnect = true; + _appendLog( + 'warn', + 'socket', + 'auto reconnect suppressed | code: ${runtimeError?.code ?? 'unknown'} | detail: ${runtimeError?.detailCode ?? 'none'}', + ); + } await _closeSocket(); + _appendLog( + 'error', + 'connect', + 'failed ${endpoint.$1}:${endpoint.$2} | code: ${runtimeError?.code ?? 'unknown'} | detail: ${runtimeError?.detailCode ?? 'none'} | message: ${error.toString()}', + ); _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.error, statusText: 'Connection failed', lastError: error.toString(), lastErrorCode: runtimeError?.code, lastErrorDetailCode: runtimeError?.detailCode, + connectAuthMode: connectAuthMode, + connectAuthFields: connectAuthFields, + connectAuthSources: connectAuthSources, hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty, hasDeviceToken: deviceToken.isNotEmpty, ); notifyListeners(); if (_shouldAutoReconnect(runtimeError)) { + _appendLog( + 'warn', + 'socket', + 'scheduling reconnect in 2s | code: ${runtimeError?.code ?? 'unknown'}', + ); _scheduleReconnect(); } rethrow; @@ -265,6 +380,7 @@ class GatewayRuntime extends ChangeNotifier { Future disconnect({bool clearDesiredProfile = true}) async { _manualDisconnect = true; + _appendLog('info', 'connect', 'manual disconnect'); if (clearDesiredProfile) { _desiredProfile = null; } @@ -285,6 +401,7 @@ class GatewayRuntime extends ChangeNotifier { Future> health() async { final payload = asMap(await request('health')); _snapshot = _snapshot.copyWith(healthPayload: payload); + _appendLog('debug', 'health', 'health snapshot refreshed'); notifyListeners(); return payload; } @@ -292,6 +409,7 @@ class GatewayRuntime extends ChangeNotifier { Future> status() async { final payload = asMap(await request('status')); _snapshot = _snapshot.copyWith(statusPayload: payload); + _appendLog('debug', 'health', 'status snapshot refreshed'); notifyListeners(); return payload; } @@ -676,6 +794,7 @@ class GatewayRuntime extends ChangeNotifier { } Future approveDevicePairing(String requestId) async { + _appendLog('info', 'pairing', 'approve request $requestId'); final payload = asMap( await request( 'device.pair.approve', @@ -692,6 +811,7 @@ class GatewayRuntime extends ChangeNotifier { } Future rejectDevicePairing(String requestId) async { + _appendLog('info', 'pairing', 'reject request $requestId'); await request( 'device.pair.reject', params: {'requestId': requestId}, @@ -700,6 +820,7 @@ class GatewayRuntime extends ChangeNotifier { } Future removePairedDevice(String deviceId) async { + _appendLog('info', 'pairing', 'remove device $deviceId'); await request( 'device.pair.remove', params: {'deviceId': deviceId}, @@ -712,6 +833,11 @@ class GatewayRuntime extends ChangeNotifier { required String role, List scopes = const [], }) async { + _appendLog( + 'info', + 'token', + 'rotate role token | device: $deviceId | role: $role', + ); final payload = asMap( await request( 'device.token.rotate', @@ -742,6 +868,11 @@ class GatewayRuntime extends ChangeNotifier { required String deviceId, required String role, }) async { + _appendLog( + 'info', + 'token', + 'revoke role token | device: $deviceId | role: $role', + ); await request( 'device.token.revoke', params: {'deviceId': deviceId, 'role': role}, @@ -759,6 +890,7 @@ class GatewayRuntime extends ChangeNotifier { Duration timeout = const Duration(seconds: 15), }) async { if (_channel == null || !isConnected) { + _appendLog('warn', 'rpc', 'blocked request $method | offline'); throw GatewayRuntimeException('gateway not connected', code: 'OFFLINE'); } final result = await _requestRaw(method, params: params, timeout: timeout); @@ -942,11 +1074,23 @@ class GatewayRuntime extends ChangeNotifier { if (nonce != null && !challenge.isCompleted) { challenge.complete(nonce); } + _appendLog('debug', 'connect', 'challenge received'); return; } if (event == 'health') { _snapshot = _snapshot.copyWith(healthPayload: asMap(payload)); + _appendLog('debug', 'health', 'push health update'); notifyListeners(); + } else if (event == 'device.pair.requested' || + event == 'device.pair.resolved') { + final eventPayload = asMap(payload); + _appendLog( + 'info', + 'pairing', + '$event | request: ${stringValue(eventPayload['requestId']) ?? 'unknown'} | device: ${stringValue(eventPayload['deviceId']) ?? 'unknown'}', + ); + } else if (event == 'seqGap') { + _appendLog('warn', 'sync', 'sequence gap detected'); } _events.add( GatewayPushEvent( @@ -972,6 +1116,17 @@ class GatewayRuntime extends ChangeNotifier { final payload = decoded['payload']; final error = asMap(decoded['error']); if (!ok) { + _appendLog( + 'error', + 'rpc', + 'request failed | code: ${stringValue(error['code']) ?? 'unknown'} | detail: ${stringValue(asMap(error['details'])['code']) ?? 'none'} | message: ${stringValue(error['message']) ?? 'gateway request failed'}', + ); + if (!_shouldAutoReconnectForCodes( + stringValue(error['code']), + stringValue(asMap(error['details'])['code']), + )) { + _suppressReconnect = true; + } completer.completeError( GatewayRuntimeException( stringValue(error['message']) ?? 'gateway request failed', @@ -986,9 +1141,15 @@ class GatewayRuntime extends ChangeNotifier { void _handleSocketFailure(String message) { _failPending(GatewayRuntimeException(message, code: 'SOCKET_FAILURE')); - if (_manualDisconnect) { + if (_manualDisconnect || _suppressReconnect) { + _appendLog( + 'warn', + 'socket', + 'failure ignored for reconnect | manual: $_manualDisconnect | suppressed: $_suppressReconnect | message: $message', + ); return; } + _appendLog('error', 'socket', 'failure | $message'); _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.error, statusText: 'Gateway error', @@ -1004,9 +1165,15 @@ class GatewayRuntime extends ChangeNotifier { _failPending( GatewayRuntimeException('socket closed', code: 'SOCKET_CLOSED'), ); - if (_manualDisconnect) { + if (_manualDisconnect || _suppressReconnect) { + _appendLog( + 'warn', + 'socket', + 'closed without reconnect | manual: $_manualDisconnect | suppressed: $_suppressReconnect', + ); return; } + _appendLog('warn', 'socket', 'closed by gateway'); _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.error, statusText: 'Disconnected', @@ -1030,21 +1197,27 @@ class GatewayRuntime extends ChangeNotifier { void _scheduleReconnect() { final profile = _desiredProfile; - if (_manualDisconnect || profile == null) { + if (_manualDisconnect || _suppressReconnect || profile == null) { return; } _reconnectTimer?.cancel(); _reconnectTimer = Timer(const Duration(seconds: 2), () { + _appendLog( + 'info', + 'socket', + 'reconnect firing | host: ${profile.host.trim().isEmpty ? 'setup-code' : profile.host.trim()} | port: ${profile.port}', + ); unawaited(connectProfile(profile)); }); } bool _shouldAutoReconnect(GatewayRuntimeException? error) { - if (error == null) { - return true; - } - final code = error.code?.trim().toUpperCase(); - final detailCode = error.detailCode?.trim().toUpperCase(); + return _shouldAutoReconnectForCodes(error?.code, error?.detailCode); + } + + bool _shouldAutoReconnectForCodes(String? code, String? detailCode) { + final resolvedCode = code?.trim().toUpperCase(); + final resolvedDetailCode = detailCode?.trim().toUpperCase(); const nonRetryableCodes = { 'INVALID_REQUEST', 'UNAUTHORIZED', @@ -1063,10 +1236,11 @@ class GatewayRuntime extends ChangeNotifier { 'DEVICE_IDENTITY_REQUIRED', 'CONTROL_UI_DEVICE_IDENTITY_REQUIRED', }; - if (code != null && nonRetryableCodes.contains(code)) { + if (resolvedCode != null && nonRetryableCodes.contains(resolvedCode)) { return false; } - if (detailCode != null && nonRetryableDetailCodes.contains(detailCode)) { + if (resolvedDetailCode != null && + nonRetryableDetailCodes.contains(resolvedDetailCode)) { return false; } return true; @@ -1082,6 +1256,32 @@ class GatewayRuntime extends ChangeNotifier { _failPending(GatewayRuntimeException('socket reset', code: 'SOCKET_RESET')); } + void _appendLog(String level, String category, String message) { + _logs.add( + RuntimeLogEntry( + timestampMs: DateTime.now().millisecondsSinceEpoch, + level: level, + category: category, + message: message, + ), + ); + const maxLogEntries = 250; + if (_logs.length > maxLogEntries) { + _logs.removeRange(0, _logs.length - maxLogEntries); + } + notifyListeners(); + } + + String _connectAuthSummary({ + required String mode, + required List fields, + required List sources, + }) { + final resolvedFields = fields.isEmpty ? 'none' : fields.join(', '); + final resolvedSources = sources.isEmpty ? 'none' : sources.join(' · '); + return '$mode | fields: $resolvedFields | sources: $resolvedSources'; + } + void _failPending(Object error) { final values = _pending.values.toList(growable: false); _pending.clear(); diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index adc82e27..10279796 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -646,6 +646,9 @@ class GatewayConnectionSnapshot { required this.deviceId, required this.authRole, required this.authScopes, + required this.connectAuthMode, + required this.connectAuthFields, + required this.connectAuthSources, required this.hasSharedAuth, required this.hasDeviceToken, required this.healthPayload, @@ -665,6 +668,9 @@ class GatewayConnectionSnapshot { final String? deviceId; final String? authRole; final List authScopes; + final String? connectAuthMode; + final List connectAuthFields; + final List connectAuthSources; final bool hasSharedAuth; final bool hasDeviceToken; final Map? healthPayload; @@ -687,6 +693,9 @@ class GatewayConnectionSnapshot { deviceId: null, authRole: null, authScopes: const [], + connectAuthMode: null, + connectAuthFields: const [], + connectAuthSources: const [], hasSharedAuth: false, hasDeviceToken: false, healthPayload: null, @@ -708,6 +717,9 @@ class GatewayConnectionSnapshot { String? deviceId, String? authRole, List? authScopes, + String? connectAuthMode, + List? connectAuthFields, + List? connectAuthSources, bool? hasSharedAuth, bool? hasDeviceToken, Map? healthPayload, @@ -741,6 +753,9 @@ class GatewayConnectionSnapshot { deviceId: deviceId ?? this.deviceId, authRole: authRole ?? this.authRole, authScopes: authScopes ?? this.authScopes, + connectAuthMode: connectAuthMode ?? this.connectAuthMode, + connectAuthFields: connectAuthFields ?? this.connectAuthFields, + connectAuthSources: connectAuthSources ?? this.connectAuthSources, hasSharedAuth: hasSharedAuth ?? this.hasSharedAuth, hasDeviceToken: hasDeviceToken ?? this.hasDeviceToken, healthPayload: healthPayload ?? this.healthPayload, @@ -764,6 +779,17 @@ class GatewayConnectionSnapshot { return detailCode == 'AUTH_TOKEN_MISSING' || errorText.contains('gateway token missing'); } + + String get connectAuthSummary { + final mode = connectAuthMode?.trim() ?? 'none'; + final fields = connectAuthFields.isEmpty + ? 'none' + : connectAuthFields.join(', '); + final sources = connectAuthSources.isEmpty + ? 'none' + : connectAuthSources.join(' · '); + return '$mode | fields: $fields | sources: $sources'; + } } class RuntimePackageInfo { @@ -802,6 +828,27 @@ class RuntimeDeviceInfo { } } +class RuntimeLogEntry { + const RuntimeLogEntry({ + required this.timestampMs, + required this.level, + required this.category, + required this.message, + }); + + final int timestampMs; + final String level; + final String category; + final String message; + + String get timeLabel { + final date = DateTime.fromMillisecondsSinceEpoch(timestampMs); + return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}'; + } + + String get line => '[$timeLabel] ${level.toUpperCase()} $category $message'; +} + class GatewayAgentSummary { const GatewayAgentSummary({ required this.id, diff --git a/lib/widgets/gateway_connect_dialog.dart b/lib/widgets/gateway_connect_dialog.dart index a2fe135d..1fe28f35 100644 --- a/lib/widgets/gateway_connect_dialog.dart +++ b/lib/widgets/gateway_connect_dialog.dart @@ -320,6 +320,13 @@ class _StatusBanner extends StatelessWidget { connection.remoteAddress ?? 'No active gateway target', style: theme.textTheme.bodyMedium, ), + const SizedBox(height: 8), + Text( + appText('认证诊断', 'Auth Diagnostics'), + style: theme.textTheme.labelLarge, + ), + const SizedBox(height: 4), + Text(connection.connectAuthSummary, style: theme.textTheme.bodySmall), if (connection.pairingRequired) ...[ const SizedBox(height: 8), Text( diff --git a/test/features/settings_page_test.dart b/test/features/settings_page_test.dart index acd828ea..187919bc 100644 --- a/test/features/settings_page_test.dart +++ b/test/features/settings_page_test.dart @@ -40,4 +40,43 @@ void main() { findsOneWidget, ); }); + + testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); + controller.runtime.addRuntimeLogForTest( + level: 'info', + category: 'connect', + message: 'connected remote gateway', + ); + controller.runtime.addRuntimeLogForTest( + level: 'warn', + category: 'pairing', + message: 'pairing required', + ); + + await pumpPage(tester, child: SettingsPage(controller: controller)); + + await tester.tap(find.text('诊断')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('runtime-log-card')), findsOneWidget); + expect(find.textContaining('connected remote gateway'), findsOneWidget); + expect(find.textContaining('pairing required'), findsOneWidget); + + await tester.enterText( + find.byKey(const ValueKey('runtime-log-filter')), + 'pairing', + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('connected remote gateway'), findsNothing); + expect(find.textContaining('pairing required'), findsOneWidget); + + await tester.tap(find.text('清空')); + await tester.pumpAndSettle(); + + expect(find.text('当前没有运行日志。'), findsOneWidget); + }); } diff --git a/test/runtime/gateway_runtime_test.dart b/test/runtime/gateway_runtime_test.dart index 8d5bed3b..f71daf5d 100644 --- a/test/runtime/gateway_runtime_test.dart +++ b/test/runtime/gateway_runtime_test.dart @@ -37,6 +37,23 @@ void main() { expect(server.connectAuth?['token'], 'shared-token-from-form'); expect(server.connectAuth?['deviceToken'], isNull); expect(runtime.snapshot.status, RuntimeConnectionStatus.connected); + expect(runtime.snapshot.connectAuthMode, 'shared-token'); + expect(runtime.snapshot.connectAuthFields, const ['token']); + expect(runtime.snapshot.connectAuthSources, const [ + 'shared:form', + ]); + expect( + runtime.logs.any( + (entry) => entry.message.contains('shared-token-from-form'), + ), + isFalse, + ); + expect( + runtime.logs.any( + (entry) => entry.message.contains('auth: shared-token'), + ), + isTrue, + ); }, ); @@ -74,6 +91,14 @@ void main() { expect(server.connectAuth?['deviceToken'], 'stored-device-token'); expect(runtime.snapshot.hasDeviceToken, isTrue); expect(runtime.snapshot.deviceId, identity.deviceId); + expect(runtime.snapshot.connectAuthMode, 'device-token'); + expect(runtime.snapshot.connectAuthFields, const [ + 'token', + 'deviceToken', + ]); + expect(runtime.snapshot.connectAuthSources, const [ + 'device:store', + ]); }, ); @@ -137,24 +162,91 @@ void main() { ); }, ); + + test( + 'GatewayRuntime does not auto reconnect after non-retryable pairing errors', + () async { + SharedPreferences.setMockInitialValues({}); + final store = SecureConfigStore(); + final runtime = GatewayRuntime( + store: store, + identityStore: DeviceIdentityStore(store), + ); + final server = await _FakeGatewayRuntimeServer.start( + connectErrorCode: 'INVALID_REQUEST', + connectErrorDetailCode: 'PAIRING_REQUIRED', + connectErrorMessage: 'pairing required', + closeAfterConnectError: true, + ); + addTearDown(runtime.dispose); + addTearDown(server.close); + + await expectLater( + () => runtime.connectProfile( + GatewayConnectionProfile.defaults().copyWith( + mode: RuntimeConnectionMode.local, + host: '127.0.0.1', + port: server.port, + tls: false, + useSetupCode: false, + ), + authTokenOverride: 'shared-token-from-form', + ), + throwsA(isA()), + ); + + await Future.delayed(const Duration(milliseconds: 2400)); + + expect(server.connectRequestCount, 1); + expect(runtime.snapshot.pairingRequired, isTrue); + expect( + runtime.logs.any( + (entry) => + entry.category == 'socket' && + entry.message.contains('auto reconnect suppressed'), + ), + isTrue, + ); + }, + ); } class _FakeGatewayRuntimeServer { - _FakeGatewayRuntimeServer._(this._server, {required this.currentDeviceId}); + _FakeGatewayRuntimeServer._( + this._server, { + required this.currentDeviceId, + required this.connectErrorCode, + required this.connectErrorDetailCode, + required this.connectErrorMessage, + required this.closeAfterConnectError, + }); final HttpServer _server; final String? currentDeviceId; + final String? connectErrorCode; + final String? connectErrorDetailCode; + final String? connectErrorMessage; + final bool closeAfterConnectError; Map? connectAuth; + int connectRequestCount = 0; int get port => _server.port; static Future<_FakeGatewayRuntimeServer> start({ String? currentDeviceId, + String? connectErrorCode, + String? connectErrorDetailCode, + String? connectErrorMessage, + bool closeAfterConnectError = false, }) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final fake = _FakeGatewayRuntimeServer._( server, currentDeviceId: currentDeviceId, + connectErrorCode: connectErrorCode, + connectErrorDetailCode: connectErrorDetailCode, + connectErrorMessage: connectErrorMessage, + closeAfterConnectError: closeAfterConnectError, ); unawaited(fake._serve()); return fake; @@ -187,9 +279,34 @@ class _FakeGatewayRuntimeServer { const {}; switch (method) { case 'connect': + connectRequestCount += 1; connectAuth = (params['auth'] as Map?)?.cast() ?? const {}; + if (connectErrorCode != null) { + socket.add( + jsonEncode({ + 'type': 'res', + 'id': id, + 'ok': false, + 'error': { + 'code': connectErrorCode, + 'message': connectErrorMessage ?? 'connect failed', + 'details': { + if (connectErrorDetailCode != null) + 'code': connectErrorDetailCode, + }, + }, + }), + ); + if (closeAfterConnectError) { + await socket.close( + WebSocketStatus.policyViolation, + 'connect failed', + ); + } + break; + } socket.add( jsonEncode({ 'type': 'res', diff --git a/test/widgets/gateway_connect_dialog_test.dart b/test/widgets/gateway_connect_dialog_test.dart index 1b912d64..77aed46d 100644 --- a/test/widgets/gateway_connect_dialog_test.dart +++ b/test/widgets/gateway_connect_dialog_test.dart @@ -25,6 +25,8 @@ void main() { expect(find.text('端口'), findsOneWidget); expect(find.text('TLS'), findsOneWidget); expect(find.text('共享 Token'), findsOneWidget); + expect(find.text('认证诊断'), findsOneWidget); + expect(find.textContaining('fields: none'), findsOneWidget); }, ); }