feat: add gateway device pairing controls

This commit is contained in:
Haitao Pan 2026-03-12 00:54:28 +08:00
parent e954af8468
commit 24836a3826
10 changed files with 1382 additions and 74 deletions

View File

@ -26,6 +26,7 @@ class AppController extends ChangeNotifier {
_connectorsController = ConnectorsController(_runtime);
_modelsController = ModelsController(_runtime);
_cronJobsController = CronJobsController(_runtime);
_devicesController = DevicesController(_runtime);
_tasksController = DerivedTasksController();
_attachChildListeners();
unawaited(_initialize());
@ -43,6 +44,7 @@ class AppController extends ChangeNotifier {
late final ConnectorsController _connectorsController;
late final ModelsController _modelsController;
late final CronJobsController _cronJobsController;
late final DevicesController _devicesController;
late final DerivedTasksController _tasksController;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
@ -70,6 +72,7 @@ class AppController extends ChangeNotifier {
ConnectorsController get connectorsController => _connectorsController;
ModelsController get modelsController => _modelsController;
CronJobsController get cronJobsController => _cronJobsController;
DevicesController get devicesController => _devicesController;
DerivedTasksController get tasksController => _tasksController;
GatewayConnectionSnapshot get connection => _runtime.snapshot;
@ -81,6 +84,7 @@ class AppController extends ChangeNotifier {
List<GatewayConnectorSummary> get connectors => _connectorsController.items;
List<GatewayModelSummary> get models => _modelsController.items;
List<GatewayCronJobSummary> get cronJobs => _cronJobsController.items;
GatewayDevicePairingList get devices => _devicesController.items;
String get selectedAgentId => _agentsController.selectedAgentId;
String get activeAgentName => _agentsController.activeAgentName;
String get currentSessionKey => _sessionsController.currentSessionKey;
@ -92,7 +96,10 @@ class AppController extends ChangeNotifier {
settings.assistantPermissionLevel;
bool get hasStoredGatewayCredential =>
_settingsController.secureRefs.containsKey('gateway_token') ||
_settingsController.secureRefs.containsKey('gateway_password');
_settingsController.secureRefs.containsKey('gateway_password') ||
_settingsController.secureRefs.containsKey(
'gateway_device_token_operator',
);
bool get canQuickConnectGateway {
final profile = settings.gateway;
if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) {
@ -277,6 +284,7 @@ class AppController extends ChangeNotifier {
Future<void> disconnectGateway() async {
await _runtime.disconnect(clearDesiredProfile: false);
await _settingsController.refreshDerivedState();
await _agentsController.refresh();
await _sessionsController.refresh();
_chatController.clear();
@ -285,6 +293,7 @@ class AppController extends ChangeNotifier {
await _connectorsController.refresh();
await _modelsController.refresh();
await _cronJobsController.refresh();
_devicesController.clear();
_recomputeTasks();
}
@ -305,6 +314,46 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
Future<void> refreshDevices({bool quiet = false}) async {
await _devicesController.refresh(quiet: quiet);
}
Future<void> approveDevicePairing(String requestId) async {
await _devicesController.approve(requestId);
await _settingsController.refreshDerivedState();
}
Future<void> rejectDevicePairing(String requestId) async {
await _devicesController.reject(requestId);
}
Future<void> removePairedDevice(String deviceId) async {
await _devicesController.remove(deviceId);
await _settingsController.refreshDerivedState();
}
Future<String?> rotateDeviceRoleToken({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
final token = await _devicesController.rotateToken(
deviceId: deviceId,
role: role,
scopes: scopes,
);
await _settingsController.refreshDerivedState();
return token;
}
Future<void> revokeDeviceRoleToken({
required String deviceId,
required String role,
}) async {
await _devicesController.revokeToken(deviceId: deviceId, role: role);
await _settingsController.refreshDerivedState();
}
Future<void> refreshAgents() async {
await _agentsController.refresh();
_sessionsController.configure(
@ -445,6 +494,7 @@ class AppController extends ChangeNotifier {
_connectorsController.dispose();
_modelsController.dispose();
_cronJobsController.dispose();
_devicesController.dispose();
_tasksController.dispose();
super.dispose();
}
@ -506,6 +556,8 @@ class AppController extends ChangeNotifier {
await _connectorsController.refresh();
await _modelsController.refresh();
await _cronJobsController.refresh();
await _devicesController.refresh(quiet: true);
await _settingsController.refreshDerivedState();
_recomputeTasks();
}
@ -521,6 +573,10 @@ class AppController extends ChangeNotifier {
if (event.event == 'seqGap') {
unawaited(refreshSessions());
}
if (event.event == 'device.pair.requested' ||
event.event == 'device.pair.resolved') {
unawaited(refreshDevices(quiet: true));
}
}
void _recomputeTasks() {
@ -544,6 +600,7 @@ class AppController extends ChangeNotifier {
_connectorsController.addListener(_relayChildChange);
_modelsController.addListener(_relayChildChange);
_cronJobsController.addListener(_relayChildChange);
_devicesController.addListener(_relayChildChange);
_tasksController.addListener(_relayChildChange);
}
@ -558,6 +615,7 @@ class AppController extends ChangeNotifier {
_connectorsController.removeListener(_relayChildChange);
_modelsController.removeListener(_relayChildChange);
_cronJobsController.removeListener(_relayChildChange);
_devicesController.removeListener(_relayChildChange);
_tasksController.removeListener(_relayChildChange);
}

View File

@ -799,6 +799,16 @@ class _AssistantEmptyState extends StatelessWidget {
'输入需求后即可开始执行,结果会回到当前会话并同步到任务页。',
'Type a request to start execution. Results return to this session and the Tasks page.',
)
: connection.pairingRequired
? appText(
'当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request再重新连接。',
'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.',
)
: connection.gatewayTokenMissing
? appText(
'首次连接需要共享 Token配对完成后可继续使用本机的 device token。',
'The first connection requires a shared token; after pairing, this device can continue with its device token.',
)
: (connection.lastError?.trim().isNotEmpty == true
? connection.lastError!.trim()
: appText(

View File

@ -165,7 +165,6 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -261,7 +260,6 @@ class _SettingsPageState extends State<SettingsPage> {
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -458,6 +456,8 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
const SizedBox(height: 16),
_buildDeviceSecurityCard(context, controller),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -821,6 +821,487 @@ class _SettingsPageState extends State<SettingsPage> {
) {
return controller.saveSettings(snapshot);
}
Widget _buildDeviceSecurityCard(
BuildContext context,
AppController controller,
) {
final theme = Theme.of(context);
final connection = controller.connection;
final devices = controller.devices;
final pending = devices.pending;
final paired = devices.paired;
final authScopes = connection.authScopes.isEmpty
? appText('未协商', 'Not negotiated')
: connection.authScopes.join(', ');
return SurfaceCard(
key: const ValueKey('gateway-device-security-card'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('设备配对与角色令牌', 'Device Pairing & Role Tokens'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
appText(
'对齐 OpenClaw 的 Devices 安全机制,处理 pairing requests 和按角色下发的 device token。',
'Match OpenClaw device security: pairing requests and per-role device tokens.',
),
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: controller.runtime.isConnected
? () => controller.refreshDevices()
: null,
child: Text(appText('刷新', 'Refresh')),
),
],
),
const SizedBox(height: 16),
_InfoRow(
label: appText('本机 Device ID', 'Local Device ID'),
value: connection.deviceId ?? appText('未初始化', 'Not initialized'),
),
_InfoRow(
label: appText('当前角色', 'Current Role'),
value: connection.authRole ?? 'operator',
),
_InfoRow(label: appText('授权范围', 'Granted Scopes'), value: authScopes),
if (connection.pairingRequired) ...[
const SizedBox(height: 8),
_buildNotice(
context,
tone: theme.colorScheme.tertiaryContainer,
title: appText('需要设备审批', 'Pairing Required'),
message: appText(
'当前设备已经向 Gateway 发起配对。请在已授权的 operator 设备上审批该请求,然后重新连接。',
'This device has requested pairing. Approve it from an authorized operator device, then reconnect.',
),
),
] else if (connection.gatewayTokenMissing) ...[
const SizedBox(height: 8),
_buildNotice(
context,
tone: theme.colorScheme.errorContainer,
title: appText('缺少共享 Token', 'Shared Token Missing'),
message: appText(
'当前连接没有通过共享 token 或已配对 device token 完成鉴权。先输入共享 Token 建立首次配对,后续可切换为 device token。',
'The current connection is missing shared-token or paired device-token auth. Use a shared token for the first pairing, then continue with the device token.',
),
),
],
if ((controller.devicesController.error ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
_buildNotice(
context,
tone: theme.colorScheme.errorContainer,
title: appText('设备列表错误', 'Devices Error'),
message: controller.devicesController.error!,
),
],
const SizedBox(height: 16),
if (!controller.runtime.isConnected) ...[
Text(
appText(
'连接 Gateway 后,这里会显示待审批设备、已配对设备和角色令牌。',
'Connect the gateway to load pending devices, paired devices, and role tokens.',
),
style: theme.textTheme.bodyMedium,
),
] else ...[
Text(
appText('待审批请求', 'Pending Requests'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 10),
if (pending.isEmpty)
Text(
appText('当前没有待审批设备。', 'No pending pairing requests.'),
style: theme.textTheme.bodyMedium,
)
else
...pending.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildPendingDeviceCard(context, controller, item),
),
),
const SizedBox(height: 20),
Text(
appText('已配对设备', 'Paired Devices'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 10),
if (paired.isEmpty)
Text(
appText('当前没有已配对设备。', 'No paired devices yet.'),
style: theme.textTheme.bodyMedium,
)
else
...paired.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildPairedDeviceCard(context, controller, item),
),
),
],
],
),
);
}
Widget _buildPendingDeviceCard(
BuildContext context,
AppController controller,
GatewayPendingDevice item,
) {
final theme = Theme.of(context);
final metadata = <String>[
if ((item.role ?? '').isNotEmpty) 'role: ${item.role}',
if (item.scopes.isNotEmpty) item.scopes.join(', '),
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
_relativeTime(item.requestedAtMs),
if (item.isRepair) appText('修复请求', 'repair'),
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
SelectableText(
item.deviceId,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
Text(metadata.join(' · '), style: theme.textTheme.bodySmall),
],
),
),
const SizedBox(width: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () =>
controller.approveDevicePairing(item.requestId),
child: Text(appText('批准', 'Approve')),
),
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmDeviceAction(
context,
title: appText('拒绝配对请求', 'Reject Pairing Request'),
message: appText(
'确定拒绝 ${item.label} 的配对请求吗?',
'Reject the pairing request from ${item.label}?',
),
);
if (confirmed == true) {
await controller.rejectDevicePairing(item.requestId);
}
},
child: Text(appText('拒绝', 'Reject')),
),
],
),
],
),
),
);
}
Widget _buildPairedDeviceCard(
BuildContext context,
AppController controller,
GatewayPairedDevice item,
) {
final theme = Theme.of(context);
final meta = <String>[
if (item.roles.isNotEmpty) 'roles: ${item.roles.join(', ')}',
if (item.scopes.isNotEmpty) 'scopes: ${item.scopes.join(', ')}',
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
if (item.currentDevice) appText('当前设备', 'current device'),
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
SelectableText(
item.deviceId,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
Text(meta.join(' · '), style: theme.textTheme.bodySmall),
],
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmDeviceAction(
context,
title: appText('移除已配对设备', 'Remove Paired Device'),
message: appText(
'确定移除 ${item.label} 吗?这会使该设备需要重新配对。',
'Remove ${item.label}? The device will need pairing again.',
),
);
if (confirmed == true) {
await controller.removePairedDevice(item.deviceId);
}
},
child: Text(appText('移除', 'Remove')),
),
],
),
const SizedBox(height: 12),
if (item.tokens.isEmpty)
Text(
appText('当前没有角色令牌。', 'No role tokens.'),
style: theme.textTheme.bodySmall,
)
else
...item.tokens.map(
(token) => Padding(
padding: const EdgeInsets.only(top: 10),
child: _buildTokenRow(context, controller, item, token),
),
),
],
),
),
);
}
Widget _buildTokenRow(
BuildContext context,
AppController controller,
GatewayPairedDevice device,
GatewayDeviceTokenSummary token,
) {
final theme = Theme.of(context);
final details = <String>[
token.revoked ? appText('已撤销', 'revoked') : appText('有效', 'active'),
if (token.scopes.isNotEmpty) token.scopes.join(', '),
_relativeTime(
token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs,
),
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(token.role, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(details.join(' · '), style: theme.textTheme.bodySmall),
],
),
),
const SizedBox(width: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () async {
final nextToken = await controller.rotateDeviceRoleToken(
deviceId: device.deviceId,
role: token.role,
scopes: token.scopes,
);
if (!context.mounted ||
nextToken == null ||
nextToken.isEmpty) {
return;
}
await _showRotatedTokenDialog(
context,
device: device,
role: token.role,
token: nextToken,
);
},
child: Text(appText('轮换', 'Rotate')),
),
if (!token.revoked)
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmDeviceAction(
context,
title: appText('撤销角色令牌', 'Revoke Role Token'),
message: appText(
'确定撤销 ${device.label}${token.role} 令牌吗?',
'Revoke the ${token.role} token for ${device.label}?',
),
);
if (confirmed == true) {
await controller.revokeDeviceRoleToken(
deviceId: device.deviceId,
role: token.role,
);
}
},
child: Text(appText('撤销', 'Revoke')),
),
],
),
],
),
),
);
}
Widget _buildNotice(
BuildContext context, {
required Color tone,
required String title,
required String message,
}) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: tone,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
SelectableText(message, style: theme.textTheme.bodyMedium),
],
),
);
}
Future<bool?> _confirmDeviceAction(
BuildContext context, {
required String title,
required String message,
}) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(appText('确认', 'Confirm')),
),
],
),
);
}
Future<void> _showRotatedTokenDialog(
BuildContext context, {
required GatewayPairedDevice device,
required String role,
required String token,
}) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('新的角色令牌', 'New Role Token')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'${device.label}$role 令牌已轮换,请立即安全保存。',
'Rotated the $role token for ${device.label}. Store it securely now.',
),
),
const SizedBox(height: 12),
SelectableText(token),
],
),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('关闭', 'Close')),
),
],
),
);
}
String _relativeTime(int? timestampMs) {
if (timestampMs == null || timestampMs <= 0) {
return appText('时间未知', 'time unknown');
}
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(timestampMs),
);
if (delta.inMinutes < 1) {
return appText('刚刚', 'just now');
}
if (delta.inHours < 1) {
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
}
if (delta.inDays < 1) {
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
}
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
}
}
class _EditableField extends StatelessWidget {

View File

@ -35,10 +35,13 @@ class GatewayPushEvent {
}
class GatewayRuntimeException implements Exception {
GatewayRuntimeException(this.message, {this.code});
GatewayRuntimeException(this.message, {this.code, this.details});
final String message;
final String? code;
final Object? details;
String? get detailCode => stringValue(asMap(details)['code']);
@override
String toString() => code == null ? message : '$code: $message';
@ -109,7 +112,7 @@ class GatewayRuntime extends ChangeNotifier {
final storedPassword = (await _store.loadGatewayPassword())?.trim() ?? '';
final explicitToken = authTokenOverride.trim();
final explicitPassword = authPasswordOverride.trim();
final token = explicitToken.isNotEmpty
final sharedToken = explicitToken.isNotEmpty
? explicitToken
: storedToken.isNotEmpty
? storedToken
@ -119,12 +122,28 @@ class GatewayRuntime extends ChangeNotifier {
: storedPassword.isNotEmpty
? storedPassword
: (setupPayload?.password.trim() ?? '');
final identity = await _identityStore.loadOrCreate();
final storedDeviceToken =
(await _store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
))?.trim() ??
'';
final explicitDeviceToken = '';
final deviceToken = explicitDeviceToken.isNotEmpty
? explicitDeviceToken
: sharedToken.isEmpty
? storedDeviceToken
: '';
final authToken = sharedToken.isNotEmpty ? sharedToken : deviceToken;
if (endpoint == null) {
_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,
);
notifyListeners();
return;
@ -134,8 +153,14 @@ class GatewayRuntime extends ChangeNotifier {
status: RuntimeConnectionStatus.connecting,
statusText: 'Connecting…',
remoteAddress: '${endpoint.$1}:${endpoint.$2}',
hasSharedAuth: token.isNotEmpty || password.isNotEmpty,
deviceId: identity.deviceId,
authRole: 'operator',
authScopes: kDefaultOperatorConnectScopes,
hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty,
hasDeviceToken: deviceToken.isNotEmpty,
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
notifyListeners();
@ -165,15 +190,6 @@ class GatewayRuntime extends ChangeNotifier {
code: 'CONNECT_CHALLENGE_TIMEOUT',
),
);
final identity = await _identityStore.loadOrCreate();
final deviceToken =
(await _store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
))?.trim() ??
'';
final authToken = token.isNotEmpty ? token : deviceToken;
final connectResult = await _requestRaw(
'connect',
params: await _buildConnectParams(
@ -181,6 +197,7 @@ class GatewayRuntime extends ChangeNotifier {
identity: identity,
nonce: nonce,
authToken: authToken,
authDeviceToken: deviceToken,
authPassword: password,
),
timeout: const Duration(seconds: 12),
@ -207,21 +224,41 @@ class GatewayRuntime extends ChangeNotifier {
mainSessionKey:
stringValue(sessionDefaults['mainSessionKey']) ?? 'main',
lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch,
hasSharedAuth: token.isNotEmpty || password.isNotEmpty,
authRole: stringValue(auth['role']) ?? 'operator',
authScopes: stringList(auth['scopes']),
hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty,
hasDeviceToken:
returnedDeviceToken != null && returnedDeviceToken.isNotEmpty,
(returnedDeviceToken != null && returnedDeviceToken.isNotEmpty) ||
deviceToken.isNotEmpty,
clearLastError: true,
clearLastErrorCode: true,
clearLastErrorDetailCode: true,
);
notifyListeners();
} catch (error) {
final runtimeError = error is GatewayRuntimeException ? error : null;
if (runtimeError?.detailCode == 'AUTH_DEVICE_TOKEN_MISMATCH' &&
deviceToken.isNotEmpty &&
sharedToken.isEmpty) {
await _store.clearDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
);
}
await _closeSocket();
_snapshot = _snapshot.copyWith(
status: RuntimeConnectionStatus.error,
statusText: 'Connection failed',
lastError: error.toString(),
lastErrorCode: runtimeError?.code,
lastErrorDetailCode: runtimeError?.detailCode,
hasSharedAuth: sharedToken.isNotEmpty || password.isNotEmpty,
hasDeviceToken: deviceToken.isNotEmpty,
);
notifyListeners();
_scheduleReconnect();
if (_shouldAutoReconnect(runtimeError)) {
_scheduleReconnect();
}
rethrow;
}
}
@ -236,6 +273,9 @@ class GatewayRuntime extends ChangeNotifier {
_snapshot = GatewayConnectionSnapshot.initial(mode: _snapshot.mode)
.copyWith(
statusText: 'Offline',
deviceId: _snapshot.deviceId,
authRole: _snapshot.authRole,
authScopes: _snapshot.authScopes,
hasSharedAuth: _snapshot.hasSharedAuth,
hasDeviceToken: _snapshot.hasDeviceToken,
);
@ -611,6 +651,108 @@ class GatewayRuntime extends ChangeNotifier {
.toList(growable: false);
}
Future<GatewayDevicePairingList> listDevicePairing() async {
final payload = asMap(
await request(
'device.pair.list',
params: const <String, dynamic>{},
timeout: const Duration(seconds: 12),
),
);
final identity = await _store.loadDeviceIdentity();
return GatewayDevicePairingList(
pending: asList(
payload['pending'],
).map((item) => _parsePendingDevice(asMap(item))).toList(growable: false),
paired: asList(payload['paired'])
.map(
(item) => _parsePairedDevice(
asMap(item),
currentDeviceId: identity?.deviceId,
),
)
.toList(growable: false),
);
}
Future<GatewayPairedDevice?> approveDevicePairing(String requestId) async {
final payload = asMap(
await request(
'device.pair.approve',
params: <String, dynamic>{'requestId': requestId},
timeout: const Duration(seconds: 12),
),
);
final identity = await _store.loadDeviceIdentity();
final device = asMap(payload['device']);
if (device.isEmpty) {
return null;
}
return _parsePairedDevice(device, currentDeviceId: identity?.deviceId);
}
Future<void> rejectDevicePairing(String requestId) async {
await request(
'device.pair.reject',
params: <String, dynamic>{'requestId': requestId},
timeout: const Duration(seconds: 12),
);
}
Future<void> removePairedDevice(String deviceId) async {
await request(
'device.pair.remove',
params: <String, dynamic>{'deviceId': deviceId},
timeout: const Duration(seconds: 12),
);
}
Future<String> rotateDeviceToken({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
final payload = asMap(
await request(
'device.token.rotate',
params: <String, dynamic>{
'deviceId': deviceId,
'role': role,
if (scopes.isNotEmpty) 'scopes': scopes,
},
timeout: const Duration(seconds: 12),
),
);
final token = stringValue(payload['token']) ?? '';
final identity = await _store.loadDeviceIdentity();
final resolvedRole = stringValue(payload['role']) ?? role;
if (token.isNotEmpty &&
identity != null &&
(stringValue(payload['deviceId']) ?? deviceId) == identity.deviceId) {
await _store.saveDeviceToken(
deviceId: identity.deviceId,
role: resolvedRole,
token: token,
);
}
return token;
}
Future<void> revokeDeviceToken({
required String deviceId,
required String role,
}) async {
await request(
'device.token.revoke',
params: <String, dynamic>{'deviceId': deviceId, 'role': role},
timeout: const Duration(seconds: 12),
);
final identity = await _store.loadDeviceIdentity();
if (identity != null && deviceId == identity.deviceId) {
await _store.clearDeviceToken(deviceId: identity.deviceId, role: role);
}
}
Future<dynamic> request(
String method, {
Map<String, dynamic>? params,
@ -663,11 +805,58 @@ class GatewayRuntime extends ChangeNotifier {
}
}
GatewayPendingDevice _parsePendingDevice(Map<String, dynamic> map) {
return GatewayPendingDevice(
requestId: stringValue(map['requestId']) ?? _randomId(),
deviceId: stringValue(map['deviceId']) ?? 'unknown-device',
displayName: stringValue(map['displayName']),
role: stringValue(map['role']),
scopes: stringList(map['scopes']),
remoteIp: stringValue(map['remoteIp']),
isRepair: boolValue(map['isRepair']) ?? false,
requestedAtMs: intValue(map['ts']),
);
}
GatewayPairedDevice _parsePairedDevice(
Map<String, dynamic> map, {
String? currentDeviceId,
}) {
return GatewayPairedDevice(
deviceId: stringValue(map['deviceId']) ?? 'unknown-device',
displayName: stringValue(map['displayName']),
roles: stringList(map['roles']),
scopes: stringList(map['scopes']),
remoteIp: stringValue(map['remoteIp']),
tokens: asList(
map['tokens'],
).map((item) => _parseTokenSummary(asMap(item))).toList(growable: false),
createdAtMs: intValue(map['createdAtMs']),
approvedAtMs: intValue(map['approvedAtMs']),
currentDevice:
currentDeviceId != null &&
currentDeviceId.isNotEmpty &&
currentDeviceId == stringValue(map['deviceId']),
);
}
GatewayDeviceTokenSummary _parseTokenSummary(Map<String, dynamic> map) {
return GatewayDeviceTokenSummary(
role: stringValue(map['role']) ?? 'operator',
scopes: stringList(map['scopes']),
createdAtMs: intValue(map['createdAtMs']),
rotatedAtMs: intValue(map['rotatedAtMs']),
revokedAtMs: intValue(map['revokedAtMs']),
lastUsedAtMs: intValue(map['lastUsedAtMs']),
);
}
Future<Map<String, dynamic>> _buildConnectParams({
required GatewayConnectionProfile profile,
required LocalDeviceIdentity identity,
required String nonce,
required String authToken,
required String authDeviceToken,
required String authPassword,
}) async {
final clientId = _resolveClientId();
@ -709,10 +898,14 @@ class GatewayRuntime extends ChangeNotifier {
'permissions': const <String, bool>{},
'role': 'operator',
'scopes': kDefaultOperatorConnectScopes,
if (authToken.isNotEmpty)
'auth': <String, dynamic>{'token': authToken}
else if (authPassword.isNotEmpty)
'auth': <String, dynamic>{'password': authPassword},
if (authToken.isNotEmpty ||
authDeviceToken.isNotEmpty ||
authPassword.isNotEmpty)
'auth': <String, dynamic>{
if (authToken.isNotEmpty) 'token': authToken,
if (authDeviceToken.isNotEmpty) 'deviceToken': authDeviceToken,
if (authPassword.isNotEmpty) 'password': authPassword,
},
'locale': Platform.localeName,
'userAgent': '$kSystemAppName/$_packageInfo.version',
'device': <String, dynamic>{
@ -783,6 +976,7 @@ class GatewayRuntime extends ChangeNotifier {
GatewayRuntimeException(
stringValue(error['message']) ?? 'gateway request failed',
code: stringValue(error['code']),
details: error['details'],
),
);
return;
@ -799,6 +993,8 @@ class GatewayRuntime extends ChangeNotifier {
status: RuntimeConnectionStatus.error,
statusText: 'Gateway error',
lastError: message,
lastErrorCode: 'SOCKET_FAILURE',
lastErrorDetailCode: null,
);
notifyListeners();
_scheduleReconnect();
@ -815,6 +1011,8 @@ class GatewayRuntime extends ChangeNotifier {
status: RuntimeConnectionStatus.error,
statusText: 'Disconnected',
lastError: 'Gateway connection closed',
lastErrorCode: 'SOCKET_CLOSED',
lastErrorDetailCode: null,
);
notifyListeners();
_scheduleReconnect();
@ -841,6 +1039,39 @@ class GatewayRuntime extends ChangeNotifier {
});
}
bool _shouldAutoReconnect(GatewayRuntimeException? error) {
if (error == null) {
return true;
}
final code = error.code?.trim().toUpperCase();
final detailCode = error.detailCode?.trim().toUpperCase();
const nonRetryableCodes = <String>{
'INVALID_REQUEST',
'UNAUTHORIZED',
'NOT_PAIRED',
'AUTH_REQUIRED',
};
const nonRetryableDetailCodes = <String>{
'AUTH_REQUIRED',
'AUTH_UNAUTHORIZED',
'AUTH_TOKEN_MISSING',
'AUTH_TOKEN_MISMATCH',
'AUTH_PASSWORD_MISSING',
'AUTH_PASSWORD_MISMATCH',
'AUTH_DEVICE_TOKEN_MISMATCH',
'PAIRING_REQUIRED',
'DEVICE_IDENTITY_REQUIRED',
'CONTROL_UI_DEVICE_IDENTITY_REQUIRED',
};
if (code != null && nonRetryableCodes.contains(code)) {
return false;
}
if (detailCode != null && nonRetryableDetailCodes.contains(detailCode)) {
return false;
}
return true;
}
Future<void> _closeSocket() async {
_reconnectTimer?.cancel();
final subscription = _socketSubscription;

View File

@ -32,6 +32,11 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<void> refreshDerivedState() async {
await _reloadDerivedState();
notifyListeners();
}
Future<void> saveSnapshot(SettingsSnapshot snapshot) async {
_snapshot = snapshot;
await _store.saveSettingsSnapshot(snapshot);
@ -297,7 +302,7 @@ class SettingsController extends ChangeNotifier {
String _moduleForSecret(String key) {
if (key.contains('gateway')) {
return 'Assistant';
return key.contains('device_token') ? 'Devices' : 'Assistant';
}
if (key.contains('ollama')) {
return 'Settings';
@ -832,6 +837,129 @@ class CronJobsController extends ChangeNotifier {
}
}
class DevicesController extends ChangeNotifier {
DevicesController(this._runtime);
final GatewayRuntime _runtime;
GatewayDevicePairingList _items = const GatewayDevicePairingList.empty();
bool _loading = false;
String? _error;
GatewayDevicePairingList get items => _items;
bool get loading => _loading;
String? get error => _error;
Future<void> refresh({bool quiet = false}) async {
if (!_runtime.isConnected) {
_items = const GatewayDevicePairingList.empty();
if (!quiet) {
_error = null;
}
notifyListeners();
return;
}
if (_loading) {
return;
}
_loading = true;
if (!quiet) {
_error = null;
}
notifyListeners();
try {
_items = await _runtime.listDevicePairing();
} catch (error) {
if (!quiet) {
_error = error.toString();
}
} finally {
_loading = false;
notifyListeners();
}
}
Future<void> approve(String requestId) async {
_error = null;
notifyListeners();
try {
await _runtime.approveDevicePairing(requestId);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
Future<void> reject(String requestId) async {
_error = null;
notifyListeners();
try {
await _runtime.rejectDevicePairing(requestId);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
Future<void> remove(String deviceId) async {
_error = null;
notifyListeners();
try {
await _runtime.removePairedDevice(deviceId);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
Future<String?> rotateToken({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
_error = null;
notifyListeners();
try {
final token = await _runtime.rotateDeviceToken(
deviceId: deviceId,
role: role,
scopes: scopes,
);
await refresh(quiet: true);
return token;
} catch (error) {
_error = error.toString();
notifyListeners();
return null;
}
}
Future<void> revokeToken({
required String deviceId,
required String role,
}) async {
_error = null;
notifyListeners();
try {
await _runtime.revokeDeviceToken(deviceId: deviceId, role: role);
await refresh(quiet: true);
} catch (error) {
_error = error.toString();
notifyListeners();
}
}
void clear() {
_items = const GatewayDevicePairingList.empty();
_error = null;
_loading = false;
notifyListeners();
}
}
class DerivedTasksController extends ChangeNotifier {
List<DerivedTaskItem> _queue = const <DerivedTaskItem>[];
List<DerivedTaskItem> _running = const <DerivedTaskItem>[];

View File

@ -640,7 +640,12 @@ class GatewayConnectionSnapshot {
required this.remoteAddress,
required this.mainSessionKey,
required this.lastError,
required this.lastErrorCode,
required this.lastErrorDetailCode,
required this.lastConnectedAtMs,
required this.deviceId,
required this.authRole,
required this.authScopes,
required this.hasSharedAuth,
required this.hasDeviceToken,
required this.healthPayload,
@ -654,7 +659,12 @@ class GatewayConnectionSnapshot {
final String? remoteAddress;
final String? mainSessionKey;
final String? lastError;
final String? lastErrorCode;
final String? lastErrorDetailCode;
final int? lastConnectedAtMs;
final String? deviceId;
final String? authRole;
final List<String> authScopes;
final bool hasSharedAuth;
final bool hasDeviceToken;
final Map<String, dynamic>? healthPayload;
@ -671,7 +681,12 @@ class GatewayConnectionSnapshot {
remoteAddress: null,
mainSessionKey: null,
lastError: null,
lastErrorCode: null,
lastErrorDetailCode: null,
lastConnectedAtMs: null,
deviceId: null,
authRole: null,
authScopes: const <String>[],
hasSharedAuth: false,
hasDeviceToken: false,
healthPayload: null,
@ -687,7 +702,12 @@ class GatewayConnectionSnapshot {
String? remoteAddress,
String? mainSessionKey,
String? lastError,
String? lastErrorCode,
String? lastErrorDetailCode,
int? lastConnectedAtMs,
String? deviceId,
String? authRole,
List<String>? authScopes,
bool? hasSharedAuth,
bool? hasDeviceToken,
Map<String, dynamic>? healthPayload,
@ -696,6 +716,8 @@ class GatewayConnectionSnapshot {
bool clearRemoteAddress = false,
bool clearMainSessionKey = false,
bool clearLastError = false,
bool clearLastErrorCode = false,
bool clearLastErrorDetailCode = false,
}) {
return GatewayConnectionSnapshot(
status: status ?? this.status,
@ -709,13 +731,39 @@ class GatewayConnectionSnapshot {
? null
: (mainSessionKey ?? this.mainSessionKey),
lastError: clearLastError ? null : (lastError ?? this.lastError),
lastErrorCode: clearLastErrorCode
? null
: (lastErrorCode ?? this.lastErrorCode),
lastErrorDetailCode: clearLastErrorDetailCode
? null
: (lastErrorDetailCode ?? this.lastErrorDetailCode),
lastConnectedAtMs: lastConnectedAtMs ?? this.lastConnectedAtMs,
deviceId: deviceId ?? this.deviceId,
authRole: authRole ?? this.authRole,
authScopes: authScopes ?? this.authScopes,
hasSharedAuth: hasSharedAuth ?? this.hasSharedAuth,
hasDeviceToken: hasDeviceToken ?? this.hasDeviceToken,
healthPayload: healthPayload ?? this.healthPayload,
statusPayload: statusPayload ?? this.statusPayload,
);
}
bool get pairingRequired {
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
final errorCode = lastErrorCode?.trim().toUpperCase();
final errorText = lastError?.toLowerCase() ?? '';
return status != RuntimeConnectionStatus.connected &&
(detailCode == 'PAIRING_REQUIRED' ||
errorCode == 'NOT_PAIRED' ||
errorText.contains('pairing required'));
}
bool get gatewayTokenMissing {
final detailCode = lastErrorDetailCode?.trim().toUpperCase();
final errorText = lastError?.toLowerCase() ?? '';
return detailCode == 'AUTH_TOKEN_MISSING' ||
errorText.contains('gateway token missing');
}
}
class RuntimePackageInfo {
@ -1019,6 +1067,93 @@ class GatewayCronJobSummary {
final String? lastError;
}
class GatewayDevicePairingList {
const GatewayDevicePairingList({required this.pending, required this.paired});
final List<GatewayPendingDevice> pending;
final List<GatewayPairedDevice> paired;
const GatewayDevicePairingList.empty()
: pending = const <GatewayPendingDevice>[],
paired = const <GatewayPairedDevice>[];
}
class GatewayPendingDevice {
const GatewayPendingDevice({
required this.requestId,
required this.deviceId,
required this.displayName,
required this.role,
required this.scopes,
required this.remoteIp,
required this.isRepair,
required this.requestedAtMs,
});
final String requestId;
final String deviceId;
final String? displayName;
final String? role;
final List<String> scopes;
final String? remoteIp;
final bool isRepair;
final int? requestedAtMs;
String get label {
final display = displayName?.trim() ?? '';
return display.isEmpty ? deviceId : display;
}
}
class GatewayPairedDevice {
const GatewayPairedDevice({
required this.deviceId,
required this.displayName,
required this.roles,
required this.scopes,
required this.remoteIp,
required this.tokens,
required this.createdAtMs,
required this.approvedAtMs,
required this.currentDevice,
});
final String deviceId;
final String? displayName;
final List<String> roles;
final List<String> scopes;
final String? remoteIp;
final List<GatewayDeviceTokenSummary> tokens;
final int? createdAtMs;
final int? approvedAtMs;
final bool currentDevice;
String get label {
final display = displayName?.trim() ?? '';
return display.isEmpty ? deviceId : display;
}
}
class GatewayDeviceTokenSummary {
const GatewayDeviceTokenSummary({
required this.role,
required this.scopes,
required this.createdAtMs,
required this.rotatedAtMs,
required this.revokedAtMs,
required this.lastUsedAtMs,
});
final String role;
final List<String> scopes;
final int? createdAtMs;
final int? rotatedAtMs;
final int? revokedAtMs;
final int? lastUsedAtMs;
bool get revoked => revokedAtMs != null;
}
class SecretReferenceEntry {
const SecretReferenceEntry({
required this.name,

View File

@ -151,10 +151,25 @@ class SecureConfigStore {
await _writeSecure(_deviceTokenKey(deviceId, role), token);
}
Future<void> clearDeviceToken({
required String deviceId,
required String role,
}) async {
await initialize();
await _deleteSecure(_deviceTokenKey(deviceId, role));
}
Future<Map<String, String>> loadSecureRefs() async {
await initialize();
final gatewayToken = await loadGatewayToken();
final gatewayPassword = await loadGatewayPassword();
final deviceIdentity = await loadDeviceIdentity();
final deviceToken = deviceIdentity == null
? null
: await loadDeviceToken(
deviceId: deviceIdentity.deviceId,
role: 'operator',
);
final ollamaKey = await loadOllamaCloudApiKey();
final vaultToken = await loadVaultToken();
return {
@ -164,6 +179,9 @@ class SecureConfigStore {
...?gatewayPassword == null
? null
: <String, String>{'gateway_password': gatewayPassword},
...?deviceToken == null
? null
: <String, String>{'gateway_device_token_operator': deviceToken},
...?ollamaKey == null
? null
: <String, String>{'ollama_cloud_api_key': ollamaKey},

View File

@ -320,6 +320,25 @@ class _StatusBanner extends StatelessWidget {
connection.remoteAddress ?? 'No active gateway target',
style: theme.textTheme.bodyMedium,
),
if (connection.pairingRequired) ...[
const SizedBox(height: 8),
Text(
appText(
'当前设备需要先完成配对审批。请在已授权设备上批准该请求后重试。',
'This device must be approved first. Approve the pairing request from an authorized device and try again.',
),
style: theme.textTheme.bodySmall,
),
] else if (connection.gatewayTokenMissing) ...[
const SizedBox(height: 8),
Text(
appText(
'首次连接请提供共享 Token配对完成后可继续使用本机 device token。',
'Provide a shared token for the first connection; after pairing, this device can continue with its device token.',
),
style: theme.textTheme.bodySmall,
),
],
if ((connection.lastError ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(connection.lastError!, style: theme.textTheme.bodySmall),

View File

@ -23,4 +23,21 @@ void main() {
await tester.pumpAndSettle();
expect(controller.themeMode, ThemeMode.light);
});
testWidgets('SettingsPage gateway tab exposes device pairing controls', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
await pumpPage(tester, child: SettingsPage(controller: controller));
await tester.tap(find.text('集成'));
await tester.pumpAndSettle();
expect(find.text('打开连接面板'), findsOneWidget);
expect(
find.byKey(const ValueKey('gateway-device-security-card')),
findsOneWidget,
);
});
}

View File

@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
test(
'GatewayRuntime uses explicit auth override for the initial connect handshake',
'GatewayRuntime uses explicit shared token override for the initial connect handshake',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
@ -19,33 +19,181 @@ void main() {
store: store,
identityStore: DeviceIdentityStore(store),
);
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final handshakeSeen = Completer<void>();
Map<String, dynamic>? receivedAuth;
final server = await _FakeGatewayRuntimeServer.start();
addTearDown(runtime.dispose);
addTearDown(server.close);
unawaited(() async {
await for (final request in server) {
final socket = await WebSocketTransformer.upgrade(request);
socket.add(
jsonEncode(<String, dynamic>{
'type': 'event',
'event': 'connect.challenge',
'payload': <String, dynamic>{'nonce': 'nonce-1'},
}),
);
await 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',
);
await for (final raw in socket) {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['type'] != 'req' || frame['method'] != 'connect') {
continue;
}
receivedAuth =
(frame['params'] as Map<String, dynamic>)['auth']
as Map<String, dynamic>?;
expect(server.connectAuth?['token'], 'shared-token-from-form');
expect(server.connectAuth?['deviceToken'], isNull);
expect(runtime.snapshot.status, RuntimeConnectionStatus.connected);
},
);
test(
'GatewayRuntime sends stored operator device token using auth.deviceToken',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
await store.saveDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
token: 'stored-device-token',
);
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
);
final server = await _FakeGatewayRuntimeServer.start();
addTearDown(runtime.dispose);
addTearDown(server.close);
await runtime.connectProfile(
GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
),
);
expect(server.connectAuth?['token'], 'stored-device-token');
expect(server.connectAuth?['deviceToken'], 'stored-device-token');
expect(runtime.snapshot.hasDeviceToken, isTrue);
expect(runtime.snapshot.deviceId, identity.deviceId);
},
);
test(
'GatewayRuntime parses device pairing state and syncs rotated local role tokens',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = SecureConfigStore();
final identityStore = DeviceIdentityStore(store);
final identity = await identityStore.loadOrCreate();
final runtime = GatewayRuntime(
store: store,
identityStore: identityStore,
);
final server = await _FakeGatewayRuntimeServer.start(
currentDeviceId: identity.deviceId,
);
addTearDown(runtime.dispose);
addTearDown(server.close);
await 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',
);
final devices = await runtime.listDevicePairing();
expect(devices.pending.single.requestId, 'req-1');
expect(devices.paired.single.currentDevice, isTrue);
expect(devices.paired.single.tokens.single.role, 'operator');
final rotated = await runtime.rotateDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
scopes: const <String>['operator.admin', 'operator.pairing'],
);
expect(rotated, 'rotated-local-device-token');
expect(
await store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
),
'rotated-local-device-token',
);
await runtime.revokeDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
);
expect(
await store.loadDeviceToken(
deviceId: identity.deviceId,
role: 'operator',
),
isNull,
);
},
);
}
class _FakeGatewayRuntimeServer {
_FakeGatewayRuntimeServer._(this._server, {required this.currentDeviceId});
final HttpServer _server;
final String? currentDeviceId;
Map<String, dynamic>? connectAuth;
int get port => _server.port;
static Future<_FakeGatewayRuntimeServer> start({
String? currentDeviceId,
}) async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _FakeGatewayRuntimeServer._(
server,
currentDeviceId: currentDeviceId,
);
unawaited(fake._serve());
return fake;
}
Future<void> close() async {
await _server.close(force: true);
}
Future<void> _serve() async {
await for (final request in _server) {
final socket = await WebSocketTransformer.upgrade(request);
socket.add(
jsonEncode(<String, dynamic>{
'type': 'event',
'event': 'connect.challenge',
'payload': <String, dynamic>{'nonce': 'nonce-1'},
}),
);
await for (final raw in socket) {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['type'] != 'req') {
continue;
}
final method = frame['method'] as String? ?? '';
final id = frame['id'] as String? ?? 'req-id';
final params =
(frame['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
switch (method) {
case 'connect':
connectAuth =
(params['auth'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': frame['id'],
'id': id,
'ok': true,
'payload': <String, dynamic>{
'server': <String, dynamic>{'host': '127.0.0.1'},
@ -54,37 +202,100 @@ void main() {
'mainSessionKey': 'main',
},
},
'auth': <String, dynamic>{
'role': 'operator',
'scopes': const <String>[
'operator.admin',
'operator.pairing',
],
},
},
}),
);
if (!handshakeSeen.isCompleted) {
handshakeSeen.complete();
}
break;
}
case 'device.pair.list':
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'pending': <Map<String, dynamic>>[
<String, dynamic>{
'requestId': 'req-1',
'deviceId': 'device-pending',
'displayName': 'Pending Device',
'role': 'operator',
'scopes': const <String>['operator.read'],
'remoteIp': '10.0.0.8',
'ts': 1700000000000,
},
],
'paired': <Map<String, dynamic>>[
<String, dynamic>{
'deviceId': currentDeviceId ?? 'device-current',
'displayName': 'Current Device',
'roles': const <String>['operator'],
'scopes': const <String>[
'operator.admin',
'operator.pairing',
],
'tokens': <Map<String, dynamic>>[
<String, dynamic>{
'role': 'operator',
'scopes': const <String>[
'operator.admin',
'operator.pairing',
],
'createdAtMs': 1700000001000,
},
],
},
],
},
}),
);
break;
case 'device.token.rotate':
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'deviceId': params['deviceId'],
'role': params['role'],
'token': 'rotated-local-device-token',
'scopes': params['scopes'] ?? const <String>[],
},
}),
);
break;
case 'device.token.revoke':
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'deviceId': params['deviceId'],
'role': params['role'],
},
}),
);
break;
default:
socket.add(
jsonEncode(<String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': const <String, dynamic>{},
}),
);
break;
}
}());
final profile = GatewayConnectionProfile.defaults().copyWith(
mode: RuntimeConnectionMode.local,
host: '127.0.0.1',
port: server.port,
tls: false,
useSetupCode: false,
);
await runtime.connectProfile(
profile,
authTokenOverride: 'shared-token-from-form',
);
await handshakeSeen.future.timeout(const Duration(seconds: 2));
expect(receivedAuth?['token'], 'shared-token-from-form');
expect(runtime.snapshot.status, RuntimeConnectionStatus.connected);
await runtime.disconnect();
runtime.dispose();
await server.close(force: true);
},
);
}
}
}
}