feat: add gateway device pairing controls
This commit is contained in:
parent
e954af8468
commit
24836a3826
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>[];
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user