fix: WebRTC remote desktop connection, cleanup local fallback, and ignore .gradle cache
This commit is contained in:
parent
6dc2d4b476
commit
78fb5329bd
3
.gitignore
vendored
3
.gitignore
vendored
@ -64,3 +64,6 @@ app.*.map.json
|
||||
/rust/Cargo.lock
|
||||
/macos/Frameworks/*.dylib
|
||||
/macos/Frameworks/*.a
|
||||
|
||||
# Gradle artifacts (including third_party)
|
||||
**/.gradle/
|
||||
|
||||
@ -232,11 +232,6 @@ extension AppControllerDesktopGateway on AppController {
|
||||
await refreshGatewayHealth();
|
||||
await refreshAgents();
|
||||
await refreshSessions();
|
||||
await skillsControllerInternal.refresh(
|
||||
agentId: agentsControllerInternal.selectedAgentId.isEmpty
|
||||
? null
|
||||
: agentsControllerInternal.selectedAgentId,
|
||||
);
|
||||
await modelsControllerInternal.refresh();
|
||||
await cronJobsControllerInternal.refresh();
|
||||
await devicesControllerInternal.refresh(quiet: true);
|
||||
|
||||
@ -179,11 +179,6 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
if (isAppOwnedAssistantSessionKeyInternal(sessionKey)) {
|
||||
await chatControllerInternal.loadSession(sessionKey);
|
||||
}
|
||||
await skillsControllerInternal.refresh(
|
||||
agentId: agentsControllerInternal.selectedAgentId.isEmpty
|
||||
? null
|
||||
: agentsControllerInternal.selectedAgentId,
|
||||
);
|
||||
recomputeTasksInternal();
|
||||
}
|
||||
|
||||
|
||||
@ -75,6 +75,7 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
required this.selectedSkillKeys,
|
||||
required this.filteredSkills,
|
||||
required this.isLoading,
|
||||
required this.errorText,
|
||||
required this.hasQuery,
|
||||
required this.onQueryChanged,
|
||||
required this.onToggleSkill,
|
||||
@ -86,6 +87,7 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
final List<String> selectedSkillKeys;
|
||||
final List<ComposerSkillOptionInternal> filteredSkills;
|
||||
final bool isLoading;
|
||||
final String? errorText;
|
||||
final bool hasQuery;
|
||||
final ValueChanged<String> onQueryChanged;
|
||||
final ValueChanged<String> onToggleSkill;
|
||||
@ -95,6 +97,7 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
final groupedSkills = skillPickerSectionsInternal(filteredSkills);
|
||||
final hasError = !isLoading && (errorText?.trim().isNotEmpty ?? false);
|
||||
return Material(
|
||||
key: const Key('assistant-skill-picker-popover'),
|
||||
color: Colors.transparent,
|
||||
@ -160,6 +163,11 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
Text(
|
||||
isLoading
|
||||
? appText('正在加载技能…', 'Loading skills…')
|
||||
: hasError
|
||||
? appText(
|
||||
'技能列表加载失败,请稍后重试。',
|
||||
'Could not load skills. Please try again.',
|
||||
)
|
||||
: hasQuery
|
||||
? appText('没有匹配的技能。', 'No matching skills.')
|
||||
: appText(
|
||||
@ -171,6 +179,18 @@ class SkillPickerPopoverInternal extends StatelessWidget {
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
if (hasError) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
errorText!.trim(),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: palette.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -95,6 +95,7 @@ Widget buildSkillPickerOverlayForInternal(
|
||||
selectedSkillKeys: state.widget.selectedSkillKeys,
|
||||
filteredSkills: state.filteredSkillOptionsInternal(),
|
||||
isLoading: state.widget.controller.skillsController.loading,
|
||||
errorText: state.widget.controller.skillsController.error,
|
||||
hasQuery: state.skillPickerQueryInternal.trim().isNotEmpty,
|
||||
onQueryChanged: state.setSkillPickerQueryInternal,
|
||||
onToggleSkill: (skillKey) => state.widget.onToggleSkill(skillKey),
|
||||
|
||||
@ -3,6 +3,33 @@ import 'dart:convert';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import '../../app/app_controller.dart';
|
||||
|
||||
String desktopConnectionStateName(RTCPeerConnectionState state) {
|
||||
final value = state.toString().split('.').last;
|
||||
return value.replaceFirst('RTCPeerConnectionState', '').toLowerCase();
|
||||
}
|
||||
|
||||
Map<String, Object?> desktopOfferParams({
|
||||
required String sessionId,
|
||||
required String? sdpOffer,
|
||||
required String display,
|
||||
required int width,
|
||||
required int height,
|
||||
required int fps,
|
||||
required int bitrate,
|
||||
required bool useGpu,
|
||||
}) {
|
||||
return {
|
||||
'sessionId': sessionId,
|
||||
'sdpOffer': sdpOffer,
|
||||
'display': display,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'fps': fps,
|
||||
'bitrate': bitrate,
|
||||
'useGpu': useGpu,
|
||||
};
|
||||
}
|
||||
|
||||
class DesktopClient {
|
||||
DesktopClient({required this.controller, required this.sessionId});
|
||||
|
||||
@ -42,7 +69,7 @@ class DesktopClient {
|
||||
try {
|
||||
final config = {
|
||||
'iceServers': [
|
||||
{'url': 'stun:stun.l.google.com:19302'}
|
||||
{'urls': 'stun:stun.l.google.com:19302'},
|
||||
],
|
||||
'sdpSemantics': 'unified-plan',
|
||||
};
|
||||
@ -60,18 +87,27 @@ class DesktopClient {
|
||||
};
|
||||
|
||||
_peerConnection!.onConnectionState = (state) {
|
||||
_stateController.add(state.toString().split('.').last);
|
||||
_stateController.add(desktopConnectionStateName(state));
|
||||
};
|
||||
|
||||
// Create data channel for inputs BEFORE creating offer
|
||||
final dcConfig = RTCDataChannelInit()..ordered = true;
|
||||
_dataChannel =
|
||||
await _peerConnection!.createDataChannel('input', dcConfig);
|
||||
_dataChannel = await _peerConnection!.createDataChannel(
|
||||
'input',
|
||||
dcConfig,
|
||||
);
|
||||
|
||||
// Handle ICE Candidates generated locally
|
||||
final List<RTCIceCandidate> iceQueue = [];
|
||||
bool isRemoteSet = false;
|
||||
|
||||
_peerConnection!.onIceCandidate = (RTCIceCandidate? candidate) {
|
||||
if (candidate != null) {
|
||||
_sendIceCandidate(candidate);
|
||||
if (isRemoteSet) {
|
||||
unawaited(_sendIceCandidate(candidate));
|
||||
} else {
|
||||
iceQueue.add(candidate);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -88,16 +124,16 @@ class DesktopClient {
|
||||
// Send SDP Offer to Bridge
|
||||
final response = await controller.gatewayAcpClientInternal.request(
|
||||
method: 'xworkmate.desktop.offer',
|
||||
params: {
|
||||
'sessionId': sessionId,
|
||||
'sdpOffer': offer.sdp,
|
||||
'display': display,
|
||||
'width': width.toString(),
|
||||
'height': height.toString(),
|
||||
'fps': fps.toString(),
|
||||
'bitrate': bitrate.toString(),
|
||||
'useGpu': useGpu.toString(),
|
||||
},
|
||||
params: desktopOfferParams(
|
||||
sessionId: sessionId,
|
||||
sdpOffer: offer.sdp,
|
||||
display: display,
|
||||
width: width,
|
||||
height: height,
|
||||
fps: fps,
|
||||
bitrate: bitrate,
|
||||
useGpu: useGpu,
|
||||
),
|
||||
);
|
||||
|
||||
final sdpAnswerData = response['result']?['sdpAnswer'];
|
||||
@ -116,6 +152,11 @@ class DesktopClient {
|
||||
answer = RTCSessionDescription(sdpAnswerData.toString(), 'answer');
|
||||
}
|
||||
await _peerConnection!.setRemoteDescription(answer);
|
||||
isRemoteSet = true;
|
||||
for (final candidate in iceQueue) {
|
||||
unawaited(_sendIceCandidate(candidate));
|
||||
}
|
||||
iceQueue.clear();
|
||||
|
||||
_isConnecting = false;
|
||||
} catch (e) {
|
||||
|
||||
@ -8,43 +8,39 @@ class DesktopInputHandler {
|
||||
final void Function(Map<String, dynamic> event) onSendInput;
|
||||
int _lastPressedButton = 1; // Default to left click
|
||||
|
||||
void handlePointerMove(PointerEvent event, Size widgetSize) {
|
||||
if (widgetSize.width == 0 || widgetSize.height == 0) return;
|
||||
|
||||
final x = event.localPosition.dx / widgetSize.width;
|
||||
final y = event.localPosition.dy / widgetSize.height;
|
||||
|
||||
final cx = x.clamp(0.0, 1.0);
|
||||
final cy = y.clamp(0.0, 1.0);
|
||||
void handlePointerMove(
|
||||
PointerEvent event,
|
||||
Size widgetSize, {
|
||||
Size? contentSize,
|
||||
}) {
|
||||
final position = desktopContentPosition(
|
||||
event.localPosition,
|
||||
widgetSize,
|
||||
contentSize: contentSize,
|
||||
);
|
||||
if (position == null) return;
|
||||
|
||||
onSendInput({
|
||||
'type': 'mouse_move',
|
||||
'x': cx,
|
||||
'y': cy,
|
||||
});
|
||||
onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy});
|
||||
}
|
||||
|
||||
void handlePointerDown(PointerDownEvent event, Size widgetSize) {
|
||||
if (widgetSize.width == 0 || widgetSize.height == 0) return;
|
||||
|
||||
final x = event.localPosition.dx / widgetSize.width;
|
||||
final y = event.localPosition.dy / widgetSize.height;
|
||||
final cx = x.clamp(0.0, 1.0);
|
||||
final cy = y.clamp(0.0, 1.0);
|
||||
void handlePointerDown(
|
||||
PointerDownEvent event,
|
||||
Size widgetSize, {
|
||||
Size? contentSize,
|
||||
}) {
|
||||
final position = desktopContentPosition(
|
||||
event.localPosition,
|
||||
widgetSize,
|
||||
contentSize: contentSize,
|
||||
);
|
||||
if (position == null) return;
|
||||
|
||||
// Send move event first to ensure click hits the exact coordinates
|
||||
onSendInput({
|
||||
'type': 'mouse_move',
|
||||
'x': cx,
|
||||
'y': cy,
|
||||
});
|
||||
onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy});
|
||||
|
||||
_lastPressedButton = _mapPointerButtons(event.buttons);
|
||||
|
||||
onSendInput({
|
||||
'type': 'mouse_down',
|
||||
'button': _lastPressedButton,
|
||||
});
|
||||
onSendInput({'type': 'mouse_down', 'button': _lastPressedButton});
|
||||
}
|
||||
|
||||
void handlePointerUp(PointerUpEvent event, Size widgetSize) {
|
||||
@ -55,29 +51,21 @@ class DesktopInputHandler {
|
||||
releasedButton = _mapPointerButtons(event.buttons);
|
||||
}
|
||||
|
||||
onSendInput({
|
||||
'type': 'mouse_up',
|
||||
'button': releasedButton,
|
||||
});
|
||||
onSendInput({'type': 'mouse_up', 'button': releasedButton});
|
||||
}
|
||||
|
||||
void handleScroll(PointerScrollEvent event) {
|
||||
// 4 = scroll up, 5 = scroll down in X11/xdotool button maps
|
||||
final button = event.scrollDelta.dy < 0 ? 4 : 5;
|
||||
onSendInput({
|
||||
'type': 'scroll',
|
||||
'button': button,
|
||||
});
|
||||
onSendInput({'type': 'scroll', 'button': button});
|
||||
}
|
||||
|
||||
void handleKeyEvent(KeyEvent event) {
|
||||
final isDown = event is KeyDownEvent || event is KeyRepeatEvent;
|
||||
final keyLabel = event.logicalKey.keyLabel;
|
||||
final keyLabel = desktopKeyName(event.logicalKey);
|
||||
if (keyLabel == null) return;
|
||||
|
||||
onSendInput({
|
||||
'type': isDown ? 'key_down' : 'key_up',
|
||||
'key': keyLabel,
|
||||
});
|
||||
onSendInput({'type': isDown ? 'key_down' : 'key_up', 'key': keyLabel});
|
||||
}
|
||||
|
||||
int _mapPointerButtons(int buttons) {
|
||||
@ -92,3 +80,89 @@ class DesktopInputHandler {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
String? desktopKeyName(LogicalKeyboardKey key) {
|
||||
if (key == LogicalKeyboardKey.enter) return 'Return';
|
||||
if (key == LogicalKeyboardKey.numpadEnter) return 'Return';
|
||||
if (key == LogicalKeyboardKey.space) return 'space';
|
||||
if (key == LogicalKeyboardKey.backspace) return 'BackSpace';
|
||||
if (key == LogicalKeyboardKey.tab) return 'Tab';
|
||||
if (key == LogicalKeyboardKey.escape) return 'Escape';
|
||||
if (key == LogicalKeyboardKey.delete) return 'Delete';
|
||||
if (key == LogicalKeyboardKey.arrowLeft) return 'Left';
|
||||
if (key == LogicalKeyboardKey.arrowRight) return 'Right';
|
||||
if (key == LogicalKeyboardKey.arrowUp) return 'Up';
|
||||
if (key == LogicalKeyboardKey.arrowDown) return 'Down';
|
||||
if (key == LogicalKeyboardKey.home) return 'Home';
|
||||
if (key == LogicalKeyboardKey.end) return 'End';
|
||||
if (key == LogicalKeyboardKey.pageUp) return 'Page_Up';
|
||||
if (key == LogicalKeyboardKey.pageDown) return 'Page_Down';
|
||||
|
||||
final label = key.keyLabel;
|
||||
if (label.isEmpty) return null;
|
||||
if (label.length == 1) {
|
||||
final punctuation = _xdotoolPunctuationNames[label];
|
||||
if (punctuation != null) return punctuation;
|
||||
if (RegExp(r'^[A-Z]$').hasMatch(label)) {
|
||||
return label.toLowerCase();
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
const Map<String, String> _xdotoolPunctuationNames = <String, String>{
|
||||
'/': 'slash',
|
||||
'.': 'period',
|
||||
',': 'comma',
|
||||
'-': 'minus',
|
||||
'_': 'underscore',
|
||||
'=': 'equal',
|
||||
';': 'semicolon',
|
||||
"'": 'apostrophe',
|
||||
'`': 'grave',
|
||||
'[': 'bracketleft',
|
||||
']': 'bracketright',
|
||||
'\\': 'backslash',
|
||||
};
|
||||
|
||||
Offset? desktopContentPosition(
|
||||
Offset localPosition,
|
||||
Size viewportSize, {
|
||||
Size? contentSize,
|
||||
}) {
|
||||
if (viewportSize.width <= 0 || viewportSize.height <= 0) return null;
|
||||
|
||||
final resolvedContentSize = contentSize;
|
||||
if (resolvedContentSize == null ||
|
||||
resolvedContentSize.width <= 0 ||
|
||||
resolvedContentSize.height <= 0) {
|
||||
return Offset(
|
||||
(localPosition.dx / viewportSize.width).clamp(0.0, 1.0),
|
||||
(localPosition.dy / viewportSize.height).clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
final viewportAspect = viewportSize.width / viewportSize.height;
|
||||
final contentAspect = resolvedContentSize.width / resolvedContentSize.height;
|
||||
|
||||
double drawnWidth;
|
||||
double drawnHeight;
|
||||
double offsetX;
|
||||
double offsetY;
|
||||
if (viewportAspect > contentAspect) {
|
||||
drawnHeight = viewportSize.height;
|
||||
drawnWidth = drawnHeight * contentAspect;
|
||||
offsetX = (viewportSize.width - drawnWidth) / 2;
|
||||
offsetY = 0;
|
||||
} else {
|
||||
drawnWidth = viewportSize.width;
|
||||
drawnHeight = drawnWidth / contentAspect;
|
||||
offsetX = 0;
|
||||
offsetY = (viewportSize.height - drawnHeight) / 2;
|
||||
}
|
||||
|
||||
return Offset(
|
||||
((localPosition.dx - offsetX) / drawnWidth).clamp(0.0, 1.0),
|
||||
((localPosition.dy - offsetY) / drawnHeight).clamp(0.0, 1.0),
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,20 +27,31 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
|
||||
late DesktopClient _client;
|
||||
DesktopInputHandler? _inputHandler;
|
||||
|
||||
|
||||
// Settings controllers
|
||||
final TextEditingController _displayController = TextEditingController(text: ':0.0');
|
||||
final TextEditingController _widthController = TextEditingController(text: '1280');
|
||||
final TextEditingController _heightController = TextEditingController(text: '720');
|
||||
final TextEditingController _fpsController = TextEditingController(text: '30');
|
||||
final TextEditingController _bitrateController = TextEditingController(text: '2000');
|
||||
|
||||
final TextEditingController _displayController = TextEditingController(
|
||||
text: ':0.0',
|
||||
);
|
||||
final TextEditingController _widthController = TextEditingController(
|
||||
text: '1280',
|
||||
);
|
||||
final TextEditingController _heightController = TextEditingController(
|
||||
text: '720',
|
||||
);
|
||||
final TextEditingController _fpsController = TextEditingController(
|
||||
text: '30',
|
||||
);
|
||||
final TextEditingController _bitrateController = TextEditingController(
|
||||
text: '2000',
|
||||
);
|
||||
|
||||
bool _useGpu = false;
|
||||
bool _showAdvancedOptions = false;
|
||||
String _connectionState = 'disconnected';
|
||||
bool _hasStream = false;
|
||||
bool _isFocused = false;
|
||||
|
||||
Size _remoteDesktopSize = const Size(1280, 720);
|
||||
|
||||
final FocusNode _viewportFocusNode = FocusNode();
|
||||
final GlobalKey _viewportKey = GlobalKey();
|
||||
|
||||
@ -55,11 +66,13 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
controller: widget.controller,
|
||||
sessionId: 'remote-desktop-session',
|
||||
);
|
||||
_inputHandler = DesktopInputHandler(onSendInput: (event) {
|
||||
if (_connectionState == 'connected') {
|
||||
_client.sendInput(event);
|
||||
}
|
||||
});
|
||||
_inputHandler = DesktopInputHandler(
|
||||
onSendInput: (event) {
|
||||
if (_connectionState == 'connected') {
|
||||
_client.sendInput(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
_streamSubscription = _client.onRemoteStream.listen((stream) {
|
||||
if (mounted) {
|
||||
@ -74,7 +87,8 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_connectionState = state.toLowerCase();
|
||||
if (_connectionState == 'disconnected' || _connectionState == 'failed') {
|
||||
if (_connectionState == 'disconnected' ||
|
||||
_connectionState == 'failed') {
|
||||
_hasStream = false;
|
||||
_localRenderer.srcObject = null;
|
||||
}
|
||||
@ -111,6 +125,7 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
final height = int.tryParse(_heightController.text) ?? 720;
|
||||
final fps = int.tryParse(_fpsController.text) ?? 30;
|
||||
final bitrate = int.tryParse(_bitrateController.text) ?? 2000;
|
||||
_remoteDesktopSize = Size(width.toDouble(), height.toDouble());
|
||||
|
||||
try {
|
||||
await _client.connect(
|
||||
@ -135,7 +150,8 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
}
|
||||
|
||||
Size _getViewportSize() {
|
||||
final renderBox = _viewportKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final renderBox =
|
||||
_viewportKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
return renderBox?.size ?? Size.zero;
|
||||
}
|
||||
|
||||
@ -168,11 +184,16 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
backgroundColor: _connectionState == 'connected'
|
||||
? Colors.redAccent
|
||||
: (_connectionState == 'connecting'
|
||||
? Colors.orangeAccent
|
||||
: theme.colorScheme.primary),
|
||||
? Colors.orangeAccent
|
||||
: theme.colorScheme.primary),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
_connectionState == 'connected'
|
||||
@ -182,26 +203,31 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
label: Text(
|
||||
_connectionState == 'connected'
|
||||
? '断开连接'
|
||||
: (_connectionState == 'connecting' ? '正在连接...' : '连接桌面'),
|
||||
: (_connectionState == 'connecting'
|
||||
? '正在连接...'
|
||||
: '连接桌面'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Status Indicator
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _connectionState == 'connected'
|
||||
? Colors.green.withValues(alpha: 0.15)
|
||||
: (_connectionState == 'connecting'
|
||||
? Colors.orange.withValues(alpha: 0.15)
|
||||
: Colors.grey.withValues(alpha: 0.15)),
|
||||
? Colors.orange.withValues(alpha: 0.15)
|
||||
: Colors.grey.withValues(alpha: 0.15)),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _connectionState == 'connected'
|
||||
? Colors.green
|
||||
: (_connectionState == 'connecting'
|
||||
? Colors.orange
|
||||
: Colors.grey),
|
||||
? Colors.orange
|
||||
: Colors.grey),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@ -215,8 +241,8 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
color: _connectionState == 'connected'
|
||||
? Colors.green
|
||||
: (_connectionState == 'connecting'
|
||||
? Colors.orange
|
||||
: Colors.grey),
|
||||
? Colors.orange
|
||||
: Colors.grey),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
@ -224,15 +250,19 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
Text(
|
||||
_connectionState == 'connected'
|
||||
? '已连接'
|
||||
: (_connectionState == 'connecting' ? '连接中' : '未连接'),
|
||||
: (_connectionState == 'connecting'
|
||||
? '连接中'
|
||||
: '未连接'),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _connectionState == 'connected'
|
||||
? Colors.green
|
||||
: (_connectionState == 'connecting'
|
||||
? Colors.orange
|
||||
: (isDark ? Colors.white70 : Colors.black87)),
|
||||
? Colors.orange
|
||||
: (isDark
|
||||
? Colors.white70
|
||||
: Colors.black87)),
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -240,15 +270,25 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
),
|
||||
// Advanced Options Toggle
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _showAdvancedOptions = !_showAdvancedOptions),
|
||||
icon: Icon(_showAdvancedOptions ? Icons.expand_less : Icons.expand_more),
|
||||
onPressed: () => setState(
|
||||
() => _showAdvancedOptions = !_showAdvancedOptions,
|
||||
),
|
||||
icon: Icon(
|
||||
_showAdvancedOptions
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
),
|
||||
label: const Text('高级选项'),
|
||||
),
|
||||
// Maximize Toggle
|
||||
if (widget.onToggleMaximize != null)
|
||||
IconButton(
|
||||
onPressed: widget.onToggleMaximize,
|
||||
icon: Icon(widget.isMaximized ? Icons.fullscreen_exit_rounded : Icons.fullscreen_rounded),
|
||||
icon: Icon(
|
||||
widget.isMaximized
|
||||
? Icons.fullscreen_exit_rounded
|
||||
: Icons.fullscreen_rounded,
|
||||
),
|
||||
tooltip: widget.isMaximized ? '恢复默认大小' : '最大化',
|
||||
),
|
||||
],
|
||||
@ -332,7 +372,7 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Stream Viewport Card
|
||||
@ -345,7 +385,9 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
});
|
||||
},
|
||||
onKeyEvent: (node, event) {
|
||||
if (_isFocused && _connectionState == 'connected' && _inputHandler != null) {
|
||||
if (_isFocused &&
|
||||
_connectionState == 'connected' &&
|
||||
_inputHandler != null) {
|
||||
_inputHandler!.handleKeyEvent(event);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@ -354,7 +396,9 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
child: Container(
|
||||
key: _viewportKey,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.black26 : Colors.black.withValues(alpha: 0.04),
|
||||
color: isDark
|
||||
? Colors.black26
|
||||
: Colors.black.withValues(alpha: 0.04),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isFocused
|
||||
@ -373,12 +417,20 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPointerHover: (event) {
|
||||
if (_inputHandler != null) {
|
||||
_inputHandler!.handlePointerMove(event, _getViewportSize());
|
||||
_inputHandler!.handlePointerMove(
|
||||
event,
|
||||
_getViewportSize(),
|
||||
contentSize: _remoteDesktopSize,
|
||||
);
|
||||
}
|
||||
},
|
||||
onPointerMove: (event) {
|
||||
if (_inputHandler != null) {
|
||||
_inputHandler!.handlePointerMove(event, _getViewportSize());
|
||||
_inputHandler!.handlePointerMove(
|
||||
event,
|
||||
_getViewportSize(),
|
||||
contentSize: _remoteDesktopSize,
|
||||
);
|
||||
}
|
||||
},
|
||||
onPointerDown: (event) {
|
||||
@ -386,22 +438,31 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
_viewportFocusNode.requestFocus();
|
||||
}
|
||||
if (_inputHandler != null) {
|
||||
_inputHandler!.handlePointerDown(event, _getViewportSize());
|
||||
_inputHandler!.handlePointerDown(
|
||||
event,
|
||||
_getViewportSize(),
|
||||
contentSize: _remoteDesktopSize,
|
||||
);
|
||||
}
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
if (_inputHandler != null) {
|
||||
_inputHandler!.handlePointerUp(event, _getViewportSize());
|
||||
_inputHandler!.handlePointerUp(
|
||||
event,
|
||||
_getViewportSize(),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPointerSignal: (event) {
|
||||
if (event is PointerScrollEvent && _inputHandler != null) {
|
||||
if (event is PointerScrollEvent &&
|
||||
_inputHandler != null) {
|
||||
_inputHandler!.handleScroll(event);
|
||||
}
|
||||
},
|
||||
child: RTCVideoView(
|
||||
_localRenderer,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
|
||||
objectFit: RTCVideoViewObjectFit
|
||||
.RTCVideoViewObjectFitContain,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -418,7 +479,9 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
Icon(
|
||||
Icons.monitor_rounded,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
color: theme.colorScheme.onSurface.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
@ -426,7 +489,8 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
? '正在建立 WebRTC 连接,请稍候...'
|
||||
: '未开启远程桌面流。点击“连接桌面”启动视频流。',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: theme.colorScheme.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
@ -449,7 +513,10 @@ class _DesktopViewState extends State<DesktopView> {
|
||||
opacity: _isFocused ? 0.3 : 0.8,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@ -56,6 +56,7 @@ class _SettingsLogsPanelState extends State<SettingsLogsPanel> {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
appLog('Failed to fetch system status: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_bridgeStatus = 'error';
|
||||
|
||||
@ -1411,6 +1411,7 @@ class GatewayAcpRuntimeSessionClient implements GatewayRuntimeSessionClient {
|
||||
required String method,
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
bool allowErrorPayload = false,
|
||||
}) async {
|
||||
final envelope = await client.request(
|
||||
method: 'xworkmate.gateway.request',
|
||||
@ -1423,6 +1424,11 @@ class GatewayAcpRuntimeSessionClient implements GatewayRuntimeSessionClient {
|
||||
onNotification: _handleNotification,
|
||||
);
|
||||
final result = client.asMap(envelope['result']);
|
||||
if (allowErrorPayload &&
|
||||
!(client.boolValue(result['ok']) ?? false) &&
|
||||
result.containsKey('payload')) {
|
||||
return result['payload'];
|
||||
}
|
||||
_throwGatewayResultIfNeeded(result, '$method request failed');
|
||||
return result['payload'];
|
||||
}
|
||||
|
||||
@ -199,14 +199,19 @@ extension GatewayRuntimeApiInternal on GatewayRuntime {
|
||||
Future<List<GatewaySkillSummary>> _listSkillsInternal({
|
||||
String? agentId,
|
||||
}) async {
|
||||
final params = <String, dynamic>{
|
||||
if (agentId != null && agentId.trim().isNotEmpty)
|
||||
'agentId': agentId.trim(),
|
||||
};
|
||||
final payload = asMap(
|
||||
await request(
|
||||
'skills.status',
|
||||
params: <String, dynamic>{
|
||||
if (agentId != null && agentId.trim().isNotEmpty)
|
||||
'agentId': agentId.trim(),
|
||||
},
|
||||
),
|
||||
sessionClientInternal == null
|
||||
? await request('skills.status', params: params)
|
||||
: await sessionClientInternal!.request(
|
||||
runtimeId: runtimeIdInternal,
|
||||
method: 'skills.status',
|
||||
params: params,
|
||||
allowErrorPayload: true,
|
||||
),
|
||||
);
|
||||
return asList(payload['skills'])
|
||||
.map((item) {
|
||||
|
||||
@ -186,6 +186,7 @@ abstract class GatewayRuntimeSessionClient {
|
||||
required String method,
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
bool allowErrorPayload = false,
|
||||
});
|
||||
|
||||
Future<void> disconnect({required String runtimeId});
|
||||
|
||||
@ -37,9 +37,6 @@ class SkillsController extends ChangeNotifier {
|
||||
errorInternal = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
await runtimeInternal.ensureBridgeSessionConnected(
|
||||
selectedAgentId: agentId?.trim() ?? '',
|
||||
);
|
||||
itemsInternal = await runtimeInternal.listSkills(agentId: agentId);
|
||||
} catch (error) {
|
||||
errorInternal = error.toString();
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.bookmarks.app-scope</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@ -635,6 +635,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
isLoading: false,
|
||||
errorText: null,
|
||||
hasQuery: false,
|
||||
onQueryChanged: (_) {},
|
||||
onToggleSkill: toggledKeys.add,
|
||||
@ -684,6 +685,7 @@ void main() {
|
||||
),
|
||||
],
|
||||
isLoading: false,
|
||||
errorText: null,
|
||||
hasQuery: true,
|
||||
onQueryChanged: (_) {},
|
||||
onToggleSkill: (_) {},
|
||||
@ -697,6 +699,38 @@ void main() {
|
||||
expect(find.text('Workspace Skills'), findsNothing);
|
||||
expect(find.text('Agent Skills'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'empty skill picker shows refresh error instead of empty state',
|
||||
(tester) async {
|
||||
final searchController = TextEditingController();
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(searchController.dispose);
|
||||
addTearDown(focusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SkillPickerPopoverInternal(
|
||||
maxHeight: 360,
|
||||
searchController: searchController,
|
||||
searchFocusNode: focusNode,
|
||||
selectedSkillKeys: const <String>[],
|
||||
filteredSkills: const <ComposerSkillOptionInternal>[],
|
||||
isLoading: false,
|
||||
errorText: 'skills.status request failed',
|
||||
hasQuery: false,
|
||||
onQueryChanged: (_) {},
|
||||
onToggleSkill: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('技能列表加载失败,请稍后重试。'), findsOneWidget);
|
||||
expect(find.text('skills.status request failed'), findsOneWidget);
|
||||
expect(find.text('当前没有已加载技能。'), findsNothing);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
46
test/features/desktop/desktop_client_test.dart
Normal file
46
test/features/desktop/desktop_client_test.dart
Normal file
@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:xworkmate/features/desktop/desktop_client.dart';
|
||||
|
||||
void main() {
|
||||
group('DesktopClient protocol helpers', () {
|
||||
test('normalizes WebRTC connection states for view gating', () {
|
||||
expect(
|
||||
desktopConnectionStateName(
|
||||
RTCPeerConnectionState.RTCPeerConnectionStateConnected,
|
||||
),
|
||||
'connected',
|
||||
);
|
||||
expect(
|
||||
desktopConnectionStateName(
|
||||
RTCPeerConnectionState.RTCPeerConnectionStateFailed,
|
||||
),
|
||||
'failed',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds desktop offer params with native numeric values', () {
|
||||
final params = desktopOfferParams(
|
||||
sessionId: 'desktop-session-1',
|
||||
sdpOffer: 'v=0',
|
||||
display: ':0.0',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
fps: 30,
|
||||
bitrate: 2000,
|
||||
useGpu: false,
|
||||
);
|
||||
|
||||
expect(params['sessionId'], 'desktop-session-1');
|
||||
expect(params['sdpOffer'], 'v=0');
|
||||
expect(params['display'], ':0.0');
|
||||
expect(params['width'], isA<int>());
|
||||
expect(params['height'], isA<int>());
|
||||
expect(params['fps'], isA<int>());
|
||||
expect(params['bitrate'], isA<int>());
|
||||
expect(params['useGpu'], isA<bool>());
|
||||
expect(params['width'], 1280);
|
||||
expect(params['height'], 720);
|
||||
});
|
||||
});
|
||||
}
|
||||
76
test/features/desktop/desktop_input_handler_test.dart
Normal file
76
test/features/desktop/desktop_input_handler_test.dart
Normal file
@ -0,0 +1,76 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:xworkmate/features/desktop/desktop_input_handler.dart';
|
||||
|
||||
void main() {
|
||||
group('desktopKeyName', () {
|
||||
test('uses xdotool-compatible names for basic terminal keys', () {
|
||||
expect(desktopKeyName(LogicalKeyboardKey.enter), 'Return');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.numpadEnter), 'Return');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.space), 'space');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.backspace), 'BackSpace');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.tab), 'Tab');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.escape), 'Escape');
|
||||
});
|
||||
|
||||
test('uses xdotool-compatible names for navigation keys', () {
|
||||
expect(desktopKeyName(LogicalKeyboardKey.arrowLeft), 'Left');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.arrowRight), 'Right');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.arrowUp), 'Up');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.arrowDown), 'Down');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.pageUp), 'Page_Up');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.pageDown), 'Page_Down');
|
||||
});
|
||||
|
||||
test('keeps printable keys available for shell and browser text input', () {
|
||||
expect(desktopKeyName(LogicalKeyboardKey.keyA), 'a');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.digit1), '1');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.period), 'period');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.slash), 'slash');
|
||||
expect(desktopKeyName(LogicalKeyboardKey.minus), 'minus');
|
||||
});
|
||||
});
|
||||
|
||||
group('desktopContentPosition', () {
|
||||
test('maps directly when viewport and content share an aspect ratio', () {
|
||||
final position = desktopContentPosition(
|
||||
const Offset(640, 360),
|
||||
const Size(1280, 720),
|
||||
contentSize: const Size(1280, 720),
|
||||
);
|
||||
|
||||
expect(position, const Offset(0.5, 0.5));
|
||||
});
|
||||
|
||||
test('subtracts horizontal letterbox before normalizing pointer input', () {
|
||||
final position = desktopContentPosition(
|
||||
const Offset(160, 360),
|
||||
const Size(1600, 720),
|
||||
contentSize: const Size(1280, 720),
|
||||
);
|
||||
|
||||
expect(position!.dx, closeTo(0.0, 0.001));
|
||||
expect(position.dy, closeTo(0.5, 0.001));
|
||||
});
|
||||
|
||||
test('subtracts vertical letterbox before normalizing pointer input', () {
|
||||
final position = desktopContentPosition(
|
||||
const Offset(640, 140),
|
||||
const Size(1280, 1000),
|
||||
contentSize: const Size(1280, 720),
|
||||
);
|
||||
|
||||
expect(position!.dx, closeTo(0.5, 0.001));
|
||||
expect(position.dy, closeTo(0.0, 0.001));
|
||||
});
|
||||
|
||||
test('keeps legacy full-viewport mapping when content size is unknown', () {
|
||||
final position = desktopContentPosition(
|
||||
const Offset(640, 360),
|
||||
const Size(1280, 720),
|
||||
);
|
||||
|
||||
expect(position, const Offset(0.5, 0.5));
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -659,6 +659,138 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'skill picker selection allows multiple skills and independent deselect',
|
||||
() async {
|
||||
final controller = _connectedGatewayController(
|
||||
_RecordingGoTaskServiceClient(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.skillsControllerInternal.itemsInternal =
|
||||
const <GatewaySkillSummary>[
|
||||
GatewaySkillSummary(
|
||||
name: 'PDF Writer',
|
||||
description: 'Write PDF documents',
|
||||
source: 'openclaw-workspace',
|
||||
skillKey: 'pdf',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
GatewaySkillSummary(
|
||||
name: 'Browser Automation',
|
||||
description: 'Use browser automation',
|
||||
source: 'agents-skills-personal',
|
||||
skillKey: 'browser-automation',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
];
|
||||
await _selectGatewaySession(controller, 'unit-skill-multi-select-task');
|
||||
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
'unit-skill-multi-select-task',
|
||||
'pdf',
|
||||
);
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
'unit-skill-multi-select-task',
|
||||
'browser-automation',
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.assistantSelectedSkillKeysForSession(
|
||||
'unit-skill-multi-select-task',
|
||||
),
|
||||
const <String>['pdf', 'browser-automation'],
|
||||
);
|
||||
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
'unit-skill-multi-select-task',
|
||||
'browser-automation',
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.assistantSelectedSkillKeysForSession(
|
||||
'unit-skill-multi-select-task',
|
||||
),
|
||||
const <String>['pdf'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('skill selection is isolated across task sessions', () async {
|
||||
final controller = _connectedGatewayController(
|
||||
_RecordingGoTaskServiceClient(),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.skillsControllerInternal.itemsInternal =
|
||||
const <GatewaySkillSummary>[
|
||||
GatewaySkillSummary(
|
||||
name: 'PDF Writer',
|
||||
description: 'Write PDF documents',
|
||||
source: 'openclaw-workspace',
|
||||
skillKey: 'pdf',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
GatewaySkillSummary(
|
||||
name: 'Browser Automation',
|
||||
description: 'Use browser automation',
|
||||
source: 'agents-skills-personal',
|
||||
skillKey: 'browser-automation',
|
||||
primaryEnv: null,
|
||||
eligible: true,
|
||||
disabled: false,
|
||||
missingBins: <String>[],
|
||||
missingEnv: <String>[],
|
||||
missingConfig: <String>[],
|
||||
),
|
||||
];
|
||||
|
||||
await _selectGatewaySession(controller, 'unit-skill-isolated-a');
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
'unit-skill-isolated-a',
|
||||
'pdf',
|
||||
);
|
||||
await _selectGatewaySession(controller, 'unit-skill-isolated-b');
|
||||
|
||||
expect(
|
||||
controller.assistantSelectedSkillKeysForSession(
|
||||
'unit-skill-isolated-b',
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
'unit-skill-isolated-b',
|
||||
'browser-automation',
|
||||
);
|
||||
|
||||
expect(
|
||||
controller
|
||||
.buildExternalAcpRoutingForSessionInternal('unit-skill-isolated-a')
|
||||
.explicitSkills,
|
||||
const <String>['pdf'],
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.buildExternalAcpRoutingForSessionInternal('unit-skill-isolated-b')
|
||||
.explicitSkills,
|
||||
const <String>['browser-automation'],
|
||||
);
|
||||
});
|
||||
|
||||
test('skill selection ignores stale non-bridge skill keys', () {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
|
||||
@ -342,6 +342,7 @@ class _CapturingGatewayRuntimeSessionClient
|
||||
required String method,
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
bool allowErrorPayload = false,
|
||||
}) {
|
||||
throw UnimplementedError('request is not used by this cleanup test');
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'SkillsController lazily connects and loads OpenClaw skills through bridge gateway request',
|
||||
'SkillsController loads OpenClaw skills through bridge request without legacy gateway connect',
|
||||
() async {
|
||||
final observedMethods = <String>[];
|
||||
final observedGatewayRequests = <Map<String, dynamic>>[];
|
||||
@ -23,31 +23,6 @@ void main() {
|
||||
observedMethods.add(method);
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
|
||||
if (method == 'xworkmate.gateway.connect') {
|
||||
request.response.write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': rpc['id'],
|
||||
'result': <String, dynamic>{
|
||||
'ok': true,
|
||||
'snapshot': <String, dynamic>{
|
||||
'status': 'connected',
|
||||
'mode': 'remote',
|
||||
'statusText': 'Connected',
|
||||
'mainSessionKey': 'main',
|
||||
},
|
||||
'auth': <String, dynamic>{
|
||||
'role': 'operator',
|
||||
'scopes': <String>['operator.read', 'operator.write'],
|
||||
},
|
||||
'returnedDeviceToken': '',
|
||||
},
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (method == 'xworkmate.gateway.request') {
|
||||
final params = (rpc['params'] as Map).cast<String, dynamic>();
|
||||
observedGatewayRequests.add(params);
|
||||
@ -159,10 +134,7 @@ void main() {
|
||||
final controller = SkillsController(runtime);
|
||||
await controller.refresh(agentId: 'main');
|
||||
|
||||
expect(observedMethods, <String>[
|
||||
'xworkmate.gateway.connect',
|
||||
'xworkmate.gateway.request',
|
||||
]);
|
||||
expect(observedMethods, const <String>['xworkmate.gateway.request']);
|
||||
expect(observedGatewayRequests.single['method'], 'skills.status');
|
||||
expect(
|
||||
(observedGatewayRequests.single['params'] as Map)['agentId'],
|
||||
@ -176,4 +148,92 @@ void main() {
|
||||
expect(controller.items.first.eligible, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'SkillsController keeps bridge skill payload when OpenClaw gateway is offline',
|
||||
() async {
|
||||
final observedGatewayRequests = <Map<String, dynamic>>[];
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final subscription = server.listen((request) async {
|
||||
final body = await utf8.decoder.bind(request).join();
|
||||
final rpc = jsonDecode(body) as Map<String, dynamic>;
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
|
||||
if (rpc['method'] == 'xworkmate.gateway.request') {
|
||||
final params = (rpc['params'] as Map).cast<String, dynamic>();
|
||||
observedGatewayRequests.add(params);
|
||||
request.response.write(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': rpc['id'],
|
||||
'result': <String, dynamic>{
|
||||
'ok': false,
|
||||
'error': <String, dynamic>{
|
||||
'code': 'OFFLINE',
|
||||
'message': 'gateway not connected',
|
||||
},
|
||||
'payload': <String, dynamic>{
|
||||
'skills': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'name': 'PDF Writer',
|
||||
'description': 'Write PDF documents',
|
||||
'source': 'openclaw-workspace',
|
||||
'skillKey': 'pdf',
|
||||
'eligible': false,
|
||||
'disabled': false,
|
||||
'missing': <String, dynamic>{
|
||||
'bins': <String>[],
|
||||
'env': <String>[],
|
||||
'config': <String>[],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await request.response.close();
|
||||
return;
|
||||
}
|
||||
|
||||
request.response.statusCode = HttpStatus.badRequest;
|
||||
await request.response.close();
|
||||
});
|
||||
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-bridge-skills-offline-test-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
appDataRootPathResolver: () async => '${tempDir.path}/settings.sqlite3',
|
||||
secretRootPathResolver: () async => tempDir.path,
|
||||
);
|
||||
final acpClient = GatewayAcpClient(
|
||||
endpointResolver: () => Uri.parse('http://127.0.0.1:${server.port}'),
|
||||
authorizationResolver: (_) async => 'bridge-token',
|
||||
);
|
||||
final runtime = GatewayRuntime(
|
||||
store: store,
|
||||
identityStore: DeviceIdentityStore(store),
|
||||
sessionClient: GatewayAcpRuntimeSessionClient(client: acpClient),
|
||||
);
|
||||
await runtime.initialize();
|
||||
addTearDown(() async {
|
||||
runtime.dispose();
|
||||
await subscription.cancel();
|
||||
await server.close(force: true);
|
||||
await tempDir.delete(recursive: true);
|
||||
});
|
||||
|
||||
final controller = SkillsController(runtime);
|
||||
await controller.refresh(agentId: 'main');
|
||||
|
||||
expect(observedGatewayRequests.single['method'], 'skills.status');
|
||||
expect(controller.error, isNull);
|
||||
expect(controller.items.map((item) => item.skillKey), const <String>[
|
||||
'pdf',
|
||||
]);
|
||||
expect(controller.items.single.eligible, isFalse);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,2 +0,0 @@
|
||||
#Sun Apr 19 11:45:58 CST 2026
|
||||
gradle.version=8.4
|
||||
Loading…
Reference in New Issue
Block a user