Remove app-side pairingRequired connection state
This commit is contained in:
parent
5e9813bf62
commit
29db450352
@ -462,6 +462,7 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
}
|
||||
|
||||
Map<String, dynamic> desktopStatusSnapshot() {
|
||||
final connectionState = currentAssistantConnectionState;
|
||||
final pausedTasks = tasksControllerInternal.scheduled
|
||||
.where((item) => item.status == 'Disabled')
|
||||
.length;
|
||||
@ -475,9 +476,9 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
final badgeCount = runningTasks + pausedTasks + timedOutTasks;
|
||||
return <String, dynamic>{
|
||||
'connectionStatus': desktopConnectionStatusValueInternal(
|
||||
connection.status,
|
||||
connectionState.status,
|
||||
),
|
||||
'connectionLabel': connection.status.label,
|
||||
'connectionLabel': connectionState.primaryLabel,
|
||||
'runningTasks': runningTasks,
|
||||
'pausedTasks': pausedTasks,
|
||||
'timedOutTasks': timedOutTasks,
|
||||
|
||||
@ -54,25 +54,32 @@ AssistantThreadConnectionState resolveGatewayThreadConnectionStateInternal({
|
||||
}) {
|
||||
final bridgeAddress = connection.remoteAddress?.trim() ?? '';
|
||||
final rawStatus = connection.status;
|
||||
final pairingRequired = connection.pairingRequired;
|
||||
final gatewayTokenMissing = connection.gatewayTokenMissing;
|
||||
final status = pairingRequired || gatewayTokenMissing
|
||||
final hasFailureEvidence =
|
||||
rawStatus == RuntimeConnectionStatus.error ||
|
||||
(connection.lastError?.trim().isNotEmpty ?? false) ||
|
||||
(connection.lastErrorCode?.trim().isNotEmpty ?? false) ||
|
||||
(connection.lastErrorDetailCode?.trim().isNotEmpty ?? false);
|
||||
final genericFailure = !gatewayTokenMissing && hasFailureEvidence;
|
||||
final status = gatewayTokenMissing || genericFailure
|
||||
? RuntimeConnectionStatus.error
|
||||
: rawStatus;
|
||||
final primaryLabel = pairingRequired
|
||||
? appText('需配对', 'Pairing Required')
|
||||
: gatewayTokenMissing
|
||||
final primaryLabel = gatewayTokenMissing
|
||||
? appText('缺少令牌', 'Missing Token')
|
||||
: genericFailure
|
||||
? appText('连接失败', 'Connection Failed')
|
||||
: status.label;
|
||||
final detailLabel = bridgeAddress.isNotEmpty
|
||||
? bridgeAddress
|
||||
: genericFailure
|
||||
? appText('xworkmate-bridge 连接失败', 'xworkmate-bridge connection failed')
|
||||
: appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected');
|
||||
return AssistantThreadConnectionState(
|
||||
executionTarget: target,
|
||||
status: status,
|
||||
primaryLabel: primaryLabel,
|
||||
detailLabel: bridgeAddress.isEmpty
|
||||
? appText('xworkmate-bridge 未连接', 'xworkmate-bridge is not connected')
|
||||
: bridgeAddress,
|
||||
detailLabel: detailLabel,
|
||||
ready: status == RuntimeConnectionStatus.connected,
|
||||
pairingRequired: pairingRequired,
|
||||
gatewayTokenMissing: gatewayTokenMissing,
|
||||
lastError: connection.lastError?.trim(),
|
||||
);
|
||||
|
||||
@ -510,16 +510,18 @@ class AssistantEmptyStateInternal extends StatelessWidget {
|
||||
'输入需求后即可开始执行,结果会回到当前会话并同步到任务页。',
|
||||
'Type a request to start execution. Results return to this session and the Tasks page.',
|
||||
)
|
||||
: connectionState.pairingRequired
|
||||
? appText(
|
||||
'当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request,再重新连接。',
|
||||
'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.',
|
||||
)
|
||||
: connectionState.gatewayTokenMissing
|
||||
? appText(
|
||||
'首次连接需要共享 Token;配对完成后可继续使用本机的 device token。',
|
||||
'The first connection requires a shared token; after pairing, this device can continue with its device token.',
|
||||
)
|
||||
: connectionState.status == RuntimeConnectionStatus.error
|
||||
? (connectionState.lastError?.trim().isNotEmpty == true
|
||||
? connectionState.lastError!.trim()
|
||||
: appText(
|
||||
'当前 bridge 连接失败。请重试连接;如果问题持续存在,请检查 bridge 运行状态和本机身份材料。',
|
||||
'The bridge connection failed. Retry the connection, and if it keeps failing, check bridge health and local device identity material.',
|
||||
))
|
||||
: !connected
|
||||
? appText(
|
||||
'当前 xworkmate-bridge 尚未连接。请先恢复 bridge 连接,再继续当前任务。',
|
||||
|
||||
@ -213,24 +213,6 @@ class MobileShellStateInternal extends State<MobileShell> {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (widget.controller.connection.pairingRequired) {
|
||||
messenger?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
appText(
|
||||
'配置码有效,已向 Gateway 发起配对请求。请先在已授权设备上审批。',
|
||||
'Setup code accepted. This device has requested pairing and now waits for approval.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
showMobileSafeSheetInternal();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
await openGatewaySetupCodeEntryInternal(prefilledSetupCode: setupCode);
|
||||
if (!mounted) {
|
||||
return;
|
||||
|
||||
@ -201,19 +201,7 @@ class MobileSafeSheetInternal extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (connection.pairingRequired) ...[
|
||||
const SizedBox(height: 12),
|
||||
MobileSafetyNoticeInternal(
|
||||
tone: palette.warning.withValues(alpha: 0.12),
|
||||
borderColor: palette.warning.withValues(alpha: 0.32),
|
||||
icon: Icons.approval_outlined,
|
||||
title: appText('需要设备审批', 'Pairing Required'),
|
||||
message: appText(
|
||||
'当前设备已经向 Gateway 发起配对。请在已授权的 operator 设备上审批,然后重新连接。',
|
||||
'This device already requested pairing. Approve it from an authorized operator device, then reconnect.',
|
||||
),
|
||||
),
|
||||
] else if (connection.gatewayTokenMissing) ...[
|
||||
if (connection.gatewayTokenMissing) ...[
|
||||
const SizedBox(height: 12),
|
||||
MobileSafetyNoticeInternal(
|
||||
tone: palette.danger.withValues(alpha: 0.1),
|
||||
|
||||
@ -309,7 +309,6 @@ class AssistantThreadConnectionState {
|
||||
required this.primaryLabel,
|
||||
required this.detailLabel,
|
||||
required this.ready,
|
||||
required this.pairingRequired,
|
||||
required this.gatewayTokenMissing,
|
||||
required this.lastError,
|
||||
});
|
||||
@ -319,7 +318,6 @@ class AssistantThreadConnectionState {
|
||||
final String primaryLabel;
|
||||
final String detailLabel;
|
||||
final bool ready;
|
||||
final bool pairingRequired;
|
||||
final bool gatewayTokenMissing;
|
||||
final String? lastError;
|
||||
|
||||
|
||||
@ -158,18 +158,6 @@ class GatewayConnectionSnapshot {
|
||||
);
|
||||
}
|
||||
|
||||
bool get pairingRequired {
|
||||
if (status == RuntimeConnectionStatus.connected) {
|
||||
return false;
|
||||
}
|
||||
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
|
||||
final errorCode = lastErrorCode?.trim().toUpperCase();
|
||||
final errorText = lastError?.toLowerCase() ?? '';
|
||||
return detailCode == 'PAIRING_REQUIRED' ||
|
||||
errorCode == 'NOT_PAIRED' ||
|
||||
errorText.contains('pairing required');
|
||||
}
|
||||
|
||||
bool get gatewayTokenMissing {
|
||||
if (status == RuntimeConnectionStatus.connected) {
|
||||
return false;
|
||||
|
||||
124
test/features/assistant/assistant_connection_status_test.dart
Normal file
124
test/features/assistant/assistant_connection_status_test.dart
Normal file
@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_main.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
group('Assistant connection status surfaces', () {
|
||||
testWidgets('shows connection failed chip for generic bridge failures', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
controller.runtimeInternal.snapshotInternal = controller
|
||||
.runtimeInternal
|
||||
.snapshot
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
remoteAddress: 'openclaw.svc.plus:443',
|
||||
lastError: 'unsupported Ed25519 private key length: 0',
|
||||
lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED',
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||
addTearDown(() async {
|
||||
await tester.binding.setSurfaceSize(null);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildAssistantPage(controller));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-connection-chip')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('连接失败 · openclaw.svc.plus:443'), findsOneWidget);
|
||||
expect(find.text('离线 · xworkmate-bridge 未连接'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows failure empty state for generic bridge errors', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
controller.runtimeInternal.snapshotInternal = controller
|
||||
.runtimeInternal
|
||||
.snapshot
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
lastError: 'unsupported Ed25519 private key length: 0',
|
||||
lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED',
|
||||
clearRemoteAddress: true,
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||
addTearDown(() async {
|
||||
await tester.binding.setSurfaceSize(null);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildAssistantPage(controller));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-empty-state-card')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Bridge 连接失败'), findsOneWidget);
|
||||
expect(
|
||||
find.text('unsupported Ed25519 private key length: 0'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('先连接 Bridge'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows offline empty state only for true offline', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
controller.runtimeInternal.snapshotInternal =
|
||||
GatewayConnectionSnapshot.initial(
|
||||
mode: controller.runtimeInternal.snapshot.mode,
|
||||
);
|
||||
|
||||
await tester.binding.setSurfaceSize(const Size(1440, 960));
|
||||
addTearDown(() async {
|
||||
await tester.binding.setSurfaceSize(null);
|
||||
});
|
||||
|
||||
await tester.pumpWidget(_buildAssistantPage(controller));
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-empty-state-card')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('先连接 Bridge'), findsOneWidget);
|
||||
expect(
|
||||
find.text('当前 xworkmate-bridge 尚未连接。请先恢复 bridge 连接,再继续当前任务。'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildAssistantPage(AppController controller) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: AssistantPage(
|
||||
controller: controller,
|
||||
onOpenDetail: (DetailPanelData _) {},
|
||||
),
|
||||
);
|
||||
}
|
||||
139
test/runtime/assistant_connection_state_test.dart
Normal file
139
test/runtime/assistant_connection_state_test.dart
Normal file
@ -0,0 +1,139 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
void main() {
|
||||
group('Assistant connection state', () {
|
||||
test('maps generic bridge runtime failures to connection failed', () async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
);
|
||||
|
||||
controller.runtimeInternal.snapshotInternal = controller
|
||||
.runtimeInternal
|
||||
.snapshot
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
remoteAddress: 'openclaw.svc.plus:443',
|
||||
lastError: 'unsupported Ed25519 private key length: 0',
|
||||
lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED',
|
||||
lastErrorDetailCode: null,
|
||||
);
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.error);
|
||||
expect(state.primaryLabel, '连接失败');
|
||||
expect(state.detailLabel, 'openclaw.svc.plus:443');
|
||||
});
|
||||
|
||||
test('keeps true offline state as bridge not connected', () async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
);
|
||||
|
||||
controller.runtimeInternal.snapshotInternal =
|
||||
GatewayConnectionSnapshot.initial(
|
||||
mode: controller.runtimeInternal.snapshot.mode,
|
||||
);
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.offline);
|
||||
expect(state.primaryLabel, '离线');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
});
|
||||
|
||||
test(
|
||||
'maps generic failures without address to bridge connection failed',
|
||||
() async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
);
|
||||
|
||||
controller.runtimeInternal.snapshotInternal = controller
|
||||
.runtimeInternal
|
||||
.snapshot
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
lastError: 'socket closed',
|
||||
lastErrorCode: 'SOCKET_CLOSED',
|
||||
lastErrorDetailCode: null,
|
||||
clearRemoteAddress: true,
|
||||
);
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.error);
|
||||
expect(state.primaryLabel, '连接失败');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 连接失败');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'keeps gateway token missing as dedicated app-visible state',
|
||||
() async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
);
|
||||
|
||||
controller.runtimeInternal.snapshotInternal = controller
|
||||
.runtimeInternal
|
||||
.snapshot
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
lastError: 'gateway token missing',
|
||||
lastErrorCode: 'AUTH_FAILED',
|
||||
lastErrorDetailCode: 'AUTH_TOKEN_MISSING',
|
||||
clearRemoteAddress: true,
|
||||
);
|
||||
|
||||
final state = controller.currentAssistantConnectionState;
|
||||
expect(state.status, RuntimeConnectionStatus.error);
|
||||
expect(state.primaryLabel, '缺少令牌');
|
||||
expect(state.detailLabel, 'xworkmate-bridge 未连接');
|
||||
},
|
||||
);
|
||||
|
||||
test('desktop snapshot uses derived assistant connection labels', () async {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
);
|
||||
|
||||
controller.runtimeInternal.snapshotInternal = controller
|
||||
.runtimeInternal
|
||||
.snapshot
|
||||
.copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
remoteAddress: 'openclaw.svc.plus:443',
|
||||
lastError: 'unsupported Ed25519 private key length: 0',
|
||||
lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED',
|
||||
);
|
||||
|
||||
final snapshot = controller.desktopStatusSnapshot();
|
||||
expect(snapshot['connectionStatus'], 'error');
|
||||
expect(snapshot['connectionLabel'], '连接失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user