Compare commits

..

1 Commits

Author SHA1 Message Date
rustdesk
f3bd9fa933 fixing https://github.com/rustdesk/rustdesk/issues/15261 2026-06-16 16:32:43 +08:00
17 changed files with 209 additions and 632 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="199"><path fill="#0089d6" d="M118.432 187.698c32.89-5.81 60.055-10.618 60.367-10.684l.568-.12-31.052-36.935c-17.078-20.314-31.051-37.014-31.051-37.11 0-.182 32.063-88.477 32.243-88.792.06-.105 21.88 37.567 52.893 91.32 29.035 50.323 52.973 91.815 53.195 92.203l.405.707-98.684-.012-98.684-.013 59.8-10.564zM0 176.435c0-.052 14.631-25.451 32.514-56.442l32.514-56.347 37.891-31.799C123.76 14.358 140.867.027 140.935.001c.069-.026-.205.664-.609 1.534s-18.919 40.582-41.145 88.25l-40.41 86.67-29.386.037c-16.162.02-29.385-.005-29.385-.057z"/></svg>

After

Width:  |  Height:  |  Size: 604 B

View File

@@ -1,6 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart'; import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import '../../../models/platform_model.dart'; import '../../../models/platform_model.dart';
@@ -8,136 +5,27 @@ import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/peer_card.dart'; import 'package:flutter_hbb/common/widgets/peer_card.dart';
@visibleForTesting
List<Peer> mergeAutocompletePeers({
Iterable<Peer> addressBookPeers = const [],
Iterable<Peer> groupPeers = const [],
Iterable<Peer> lanPeers = const [],
Iterable<Peer> recentPeers = const [],
Iterable<String> restRecentPeerIds = const [],
}) {
final combinedPeers = <String, Peer>{};
void addPeer(Peer peer) {
if (peer.id.isEmpty) {
return;
}
final existingPeer = combinedPeers[peer.id];
if (existingPeer == null) {
combinedPeers[peer.id] = Peer.copy(peer);
} else if (peer.online) {
existingPeer.online = true;
}
}
for (final peer in addressBookPeers) {
addPeer(peer);
}
for (final peer in groupPeers) {
addPeer(peer);
}
for (final peer in lanPeers) {
addPeer(peer);
}
for (final peer in recentPeers) {
addPeer(peer);
}
for (final id in restRecentPeerIds) {
if (id.isNotEmpty && !combinedPeers.containsKey(id)) {
combinedPeers[id] = Peer.fromJson({'id': id});
}
}
return combinedPeers.values.toList(growable: false);
}
@visibleForTesting
bool updateAutocompletePeerOnlineStates(
List<Peer> peers, {
required Set<String> onlines,
required Set<String> offlines,
}) {
var changed = false;
for (final peer in peers) {
if (onlines.contains(peer.id)) {
if (!peer.online) {
peer.online = true;
changed = true;
}
} else if (offlines.contains(peer.id)) {
if (peer.online) {
peer.online = false;
changed = true;
}
}
}
return changed;
}
@visibleForTesting
List<String> autocompleteOnlineQueryIds(
Iterable<Peer> options, {
required int limit,
}) {
final ids = <String>[];
final seenIds = <String>{};
for (final peer in options) {
if (peer.id.isEmpty || seenIds.contains(peer.id)) {
continue;
}
seenIds.add(peer.id);
ids.add(peer.id);
if (ids.length >= limit) {
break;
}
}
return ids;
}
class AllPeersLoader { class AllPeersLoader {
List<Peer> peers = []; List<Peer> peers = [];
bool _isPeersLoading = false; bool _isPeersLoading = false;
bool _isPeersLoaded = false; bool _isPeersLoaded = false;
Set<String> _lastQueryOnlineIds = {};
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
Timer? _queryOnlineTimer;
List<Peer> _lastQueryOnlineOptions = const [];
Set<String> _lastOnlineIds = {};
Set<String> _lastOfflineIds = {};
final Future<void> Function(List<String> ids) _queryOnlines;
final Duration _queryOnlineDebounce;
void Function(VoidCallback)? _setState;
bool _isCleared = false;
final String _listenerKey = 'AllPeersLoader'; final String _listenerKey = 'AllPeersLoader';
static const String _cbQueryOnlines = 'callback_query_onlines';
static const Duration _queryOnlineInterval = Duration(seconds: 5); late void Function(VoidCallback) setState;
static const Duration _defaultQueryOnlineDebounce =
Duration(milliseconds: 300);
static const int _maxQueryOnlineOptions = 20;
bool get needLoad => !_isPeersLoaded && !_isPeersLoading; bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
bool get isPeersLoaded => _isPeersLoaded; bool get isPeersLoaded => _isPeersLoaded;
AllPeersLoader({ AllPeersLoader();
@visibleForTesting Future<void> Function(List<String> ids)? queryOnlines,
@visibleForTesting Duration? queryOnlineDebounce,
}) : _queryOnlines = queryOnlines ?? ((ids) => bind.queryOnlines(ids: ids)),
_queryOnlineDebounce =
queryOnlineDebounce ?? _defaultQueryOnlineDebounce;
void init(void Function(VoidCallback) setState) { void init(void Function(VoidCallback) setState) {
_setState = setState; this.setState = setState;
_isCleared = false;
gFFI.recentPeersModel.addListener(_mergeAllPeers); gFFI.recentPeersModel.addListener(_mergeAllPeers);
gFFI.lanPeersModel.addListener(_mergeAllPeers); gFFI.lanPeersModel.addListener(_mergeAllPeers);
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers); gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
(evt) async {
_updateOnlineState(evt);
});
} }
void clear() { void clear() {
@@ -145,11 +33,6 @@ class AllPeersLoader {
gFFI.lanPeersModel.removeListener(_mergeAllPeers); gFFI.lanPeersModel.removeListener(_mergeAllPeers);
gFFI.abModel.removePeerUpdateListener(_listenerKey); gFFI.abModel.removePeerUpdateListener(_listenerKey);
gFFI.groupModel.removePeerUpdateListener(_listenerKey); gFFI.groupModel.removePeerUpdateListener(_listenerKey);
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
_queryOnlineTimer?.cancel();
_lastQueryOnlineOptions = const [];
_setState = null;
_isCleared = true;
} }
Future<void> getAllPeers() async { Future<void> getAllPeers() async {
@@ -176,106 +59,50 @@ class AllPeersLoader {
} }
void _mergeAllPeers() { void _mergeAllPeers() {
if (_isCleared) { Map<String, dynamic> combinedPeers = {};
return; for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
} }
peers = mergeAutocompletePeers( for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
addressBookPeers: gFFI.abModel.allPeers(), if (!combinedPeers.containsKey(p.id)) {
groupPeers: gFFI.groupModel.peers, combinedPeers[p.id] = p.toJson();
lanPeers: gFFI.lanPeersModel.peers, }
recentPeers: gFFI.recentPeersModel.peers, }
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
); List<Peer> parsedPeers = [];
_applyLastOnlineState(peers); for (var peer in combinedPeers.values) {
_scheduleSetState(() { parsedPeers.add(Peer.fromJson(peer));
}
Set<String> peerIds = combinedPeers.keys.toSet();
for (final peer in gFFI.lanPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final peer in gFFI.recentPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final id in gFFI.recentPeersModel.restPeerIds) {
if (!peerIds.contains(id)) {
parsedPeers.add(Peer.fromJson({'id': id}));
peerIds.add(id);
}
}
peers = parsedPeers;
setState(() {
_isPeersLoading = false; _isPeersLoading = false;
_isPeersLoaded = true; _isPeersLoaded = true;
}); });
} }
void _updateOnlineState(Map<String, dynamic> evt) {
if (_isCleared) {
return;
}
_lastOnlineIds = _splitPeerIds(evt['onlines']);
_lastOfflineIds = _splitPeerIds(evt['offlines']);
final peersChanged = _applyLastOnlineState(peers);
final optionsChanged = _applyLastOnlineState(_lastQueryOnlineOptions);
if (peersChanged || optionsChanged) {
_scheduleSetState(() {});
}
}
void _scheduleSetState(VoidCallback callback) {
if (_isCleared) {
return;
}
final setState = _setState;
if (setState == null) {
callback();
} else {
setState(callback);
}
}
bool _applyLastOnlineState(List<Peer> peers) {
return updateAutocompletePeerOnlineStates(
peers,
onlines: _lastOnlineIds,
offlines: _lastOfflineIds,
);
}
Set<String> _splitPeerIds(dynamic ids) {
if (ids is! String || ids.isEmpty) {
return {};
}
return ids.split(',').where((id) => id.isNotEmpty).toSet();
}
void queryOnlines(Iterable<Peer> options) {
if (_isCleared) {
return;
}
_lastQueryOnlineOptions = options.toList(growable: false);
final ids = autocompleteOnlineQueryIds(
_lastQueryOnlineOptions,
limit: _maxQueryOnlineOptions,
).toSet();
_queryOnlineTimer?.cancel();
_queryOnlineTimer = null;
if (ids.isEmpty) {
return;
}
final now = DateTime.now();
if (setEquals(ids, _lastQueryOnlineIds) &&
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
return;
}
_queryOnlineTimer = Timer(_queryOnlineDebounce, () async {
try {
await _queryOnlines(ids.toList(growable: false));
if (_isCleared) {
return;
}
_lastQueryOnlineIds = ids;
_lastQueryOnlineTime = DateTime.now();
} catch (e) {
debugPrint('query autocomplete online state failed: $e');
}
});
}
@visibleForTesting
void updateOnlineStateForTesting(Map<String, dynamic> evt) {
_updateOnlineState(evt);
}
@visibleForTesting
bool applyLastOnlineStateForTesting(List<Peer> peers) {
return _applyLastOnlineState(peers);
}
} }
class AutocompletePeerTile extends StatefulWidget { class AutocompletePeerTile extends StatefulWidget {

View File

@@ -24,35 +24,6 @@ const kOpSvgList = [
'microsoft' 'microsoft'
]; ];
class _OidcProviderBranding {
final String label;
final String iconKey;
const _OidcProviderBranding({
required this.label,
required this.iconKey,
});
}
_OidcProviderBranding _oidcProviderBranding(String op) {
switch (op.toLowerCase()) {
case 'azure':
return _OidcProviderBranding(
label: 'Microsoft',
iconKey: 'microsoft',
);
default:
return _OidcProviderBranding(
label: {
'github': 'GitHub',
'gitlab': 'GitLab',
}[op.toLowerCase()] ??
toCapitalized(op),
iconKey: op.toLowerCase(),
);
}
}
class _IconOP extends StatelessWidget { class _IconOP extends StatelessWidget {
final String op; final String op;
final String? icon; final String? icon;
@@ -103,8 +74,11 @@ class ButtonOP extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final branding = _oidcProviderBranding(op); final opLabel = {
final buttonLabel = translate("Continue with {${branding.label}}"); 'github': 'GitHub',
'gitlab': 'GitLab'
}[op.toLowerCase()] ??
toCapitalized(op);
return Row(children: [ return Row(children: [
Container( Container(
height: height, height: height,
@@ -121,7 +95,7 @@ class ButtonOP extends StatelessWidget {
SizedBox( SizedBox(
width: 30, width: 30,
child: _IconOP( child: _IconOP(
op: branding.iconKey, op: op,
icon: icon, icon: icon,
margin: EdgeInsets.only(right: 5), margin: EdgeInsets.only(right: 5),
), ),
@@ -129,7 +103,8 @@ class ButtonOP extends StatelessWidget {
Expanded( Expanded(
child: FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Center(child: Text(buttonLabel)), child: Center(
child: Text(translate("Continue with {$opLabel}"))),
), ),
), ),
], ],

View File

@@ -398,7 +398,6 @@ class _ConnectionPageState extends State<ConnectionPage>
.contains(textToFind) || .contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind)) peer.alias.toLowerCase().contains(textToFind))
.toList(); .toList();
_allPeersLoader.queryOnlines(_autocompleteOpts);
} }
return _autocompleteOpts; return _autocompleteOpts;
}, },

View File

@@ -207,7 +207,6 @@ class _ConnectionPageState extends State<ConnectionPage> {
.contains(textToFind) || .contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind)) peer.alias.toLowerCase().contains(textToFind))
.toList(); .toList();
_allPeersLoader.queryOnlines(_autocompleteOpts);
} }
return _autocompleteOpts; return _autocompleteOpts;
}, },

View File

@@ -55,8 +55,6 @@ import 'package:flutter_hbb/native/custom_cursor.dart'
typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id); typedef HandleMsgBox = Function(Map<String, dynamic> evt, String id);
typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool); typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool);
final _constSessionId = Uuid().v4obj(); final _constSessionId = Uuid().v4obj();
// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly.
const _restartReconnectSilentDelaySecs = 5;
class CachedPeerData { class CachedPeerData {
Map<String, dynamic> updatePrivacyMode = {}; Map<String, dynamic> updatePrivacyMode = {};
@@ -121,7 +119,6 @@ class FfiModel with ChangeNotifier {
bool _touchMode = false; bool _touchMode = false;
late VirtualMouseMode virtualMouseMode; late VirtualMouseMode virtualMouseMode;
Timer? _timer; Timer? _timer;
Timer? _restartReconnectDelayTimer;
var _reconnects = 1; var _reconnects = 1;
DateTime? _offlineReconnectStartTime; DateTime? _offlineReconnectStartTime;
bool _viewOnly = false; bool _viewOnly = false;
@@ -253,7 +250,6 @@ class FfiModel with ChangeNotifier {
_inputBlocked = false; _inputBlocked = false;
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
resetRestartReconnectState();
clearPermissions(); clearPermissions();
waitForImageTimer?.cancel(); waitForImageTimer?.cancel();
timerScreenshot?.cancel(); timerScreenshot?.cancel();
@@ -345,7 +341,6 @@ class FfiModel with ChangeNotifier {
} else if (name == 'connection_ready') { } else if (name == 'connection_ready') {
setConnectionType(peerId, evt['secure'] == 'true', setConnectionType(peerId, evt['secure'] == 'true',
evt['direct'] == 'true', evt['stream_type'] ?? ''); evt['direct'] == 'true', evt['stream_type'] ?? '');
resetRestartReconnectState();
} else if (name == 'switch_display') { } else if (name == 'switch_display') {
// switch display is kept for backward compatibility // switch display is kept for backward compatibility
handleSwitchDisplay(evt, sessionId, peerId); handleSwitchDisplay(evt, sessionId, peerId);
@@ -927,28 +922,8 @@ class FfiModel with ChangeNotifier {
enterUserLoginAndPasswordDialog( enterUserLoginAndPasswordDialog(
sessionId, dialogManager, 'terminal-admin-login-tip', false); sessionId, dialogManager, 'terminal-admin-login-tip', false);
} else if (type == 'restarting') { } else if (type == 'restarting') {
// Treat restart messages as reconnect control events. Rust still sends showMsgBox(sessionId, type, title, text, link, false, dialogManager,
// title/text for legacy UI and translation reuse; Flutter keeps the last hasCancel: false);
// frame briefly, then shows the Connecting overlay.
if (_restartReconnectDelayTimer == null) {
parent.target?.inputModel.setRelativeMouseMode(false);
bind.sessionReconnect(sessionId: sessionId, forceRelay: false);
clearPermissions();
// Retry once more after the silent window so restart reconnect attempts
// are spaced by the empirical short cadence instead of only updating UI.
_restartReconnectDelayTimer =
Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () {
_restartReconnectDelayTimer = null;
if (parent.target?.closed == true) {
return;
}
reconnect(dialogManager, sessionId, false);
});
}
} else if (type == 'restarting-show') {
_restartReconnectDelayTimer?.cancel();
_restartReconnectDelayTimer = null;
reconnect(dialogManager, sessionId, false);
} else if (type == 'wait-remote-accept-nook') { } else if (type == 'wait-remote-accept-nook') {
showWaitAcceptDialog(sessionId, type, title, text, dialogManager); showWaitAcceptDialog(sessionId, type, title, text, dialogManager);
} else if (type == 'on-uac' || type == 'on-foreground-elevated') { } else if (type == 'on-uac' || type == 'on-foreground-elevated') {
@@ -974,11 +949,6 @@ class FfiModel with ChangeNotifier {
} }
} }
void resetRestartReconnectState() {
_restartReconnectDelayTimer?.cancel();
_restartReconnectDelayTimer = null;
}
/// Auto-retry check for "Remote desktop is offline" error. /// Auto-retry check for "Remote desktop is offline" error.
/// returns true to auto-retry, false otherwise. /// returns true to auto-retry, false otherwise.
bool shouldAutoRetryOnOffline( bool shouldAutoRetryOnOffline(
@@ -1404,7 +1374,6 @@ class FfiModel with ChangeNotifier {
if (displays.isNotEmpty) { if (displays.isNotEmpty) {
_reconnects = 1; _reconnects = 1;
_offlineReconnectStartTime = null; _offlineReconnectStartTime = null;
resetRestartReconnectState();
waitForFirstImage.value = true; waitForFirstImage.value = true;
isRefreshing = false; isRefreshing = false;
} }
@@ -3697,7 +3666,6 @@ class FFI {
/// Mobile reuse FFI /// Mobile reuse FFI
void mobileReset() { void mobileReset() {
ffiModel.resetRestartReconnectState();
ffiModel.waitForFirstImage.value = true; ffiModel.waitForFirstImage.value = true;
ffiModel.isRefreshing = false; ffiModel.isRefreshing = false;
ffiModel.waitForImageDialogShow.value = true; ffiModel.waitForImageDialogShow.value = true;
@@ -3911,7 +3879,6 @@ class FFI {
} }
if (ffiModel.waitForFirstImage.value == true) { if (ffiModel.waitForFirstImage.value == true) {
ffiModel.waitForFirstImage.value = false; ffiModel.waitForFirstImage.value = false;
ffiModel.resetRestartReconnectState();
dialogManager.dismissAll(); dialogManager.dismissAll();
await canvasModel.updateViewStyle(); await canvasModel.updateViewStyle();
await canvasModel.updateScrollStyle(); await canvasModel.updateScrollStyle();

View File

@@ -145,26 +145,23 @@ class Peer {
note == other.note; note == other.note;
} }
factory Peer.copy(Peer other) { Peer.copy(Peer other)
final peer = Peer( : this(
id: other.id, id: other.id,
hash: other.hash, hash: other.hash,
password: other.password, password: other.password,
username: other.username, username: other.username,
hostname: other.hostname, hostname: other.hostname,
platform: other.platform, platform: other.platform,
alias: other.alias, alias: other.alias,
tags: other.tags.toList(), tags: other.tags.toList(),
forceAlwaysRelay: other.forceAlwaysRelay, forceAlwaysRelay: other.forceAlwaysRelay,
rdpPort: other.rdpPort, rdpPort: other.rdpPort,
rdpUsername: other.rdpUsername, rdpUsername: other.rdpUsername,
loginName: other.loginName, loginName: other.loginName,
device_group_name: other.device_group_name, device_group_name: other.device_group_name,
note: other.note, note: other.note,
sameServer: other.sameServer); sameServer: other.sameServer);
peer.online = other.online;
return peer;
}
} }
enum UpdateEvent { online, load } enum UpdateEvent { online, load }

View File

@@ -1,148 +0,0 @@
import 'package:flutter_hbb/common/widgets/autocomplete.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_test/flutter_test.dart';
Peer _peer({
required String id,
String alias = '',
String username = '',
String hostname = '',
bool online = false,
}) {
final peer = Peer(
id: id,
username: username,
hostname: hostname,
alias: alias,
platform: '',
tags: [],
hash: '',
password: '',
forceAlwaysRelay: false,
rdpPort: '',
rdpUsername: '',
loginName: '',
device_group_name: '',
note: '',
);
peer.online = online;
return peer;
}
void main() {
test('merged autocomplete peers keep address book metadata and online state',
() {
final peers = mergeAutocompletePeers(
addressBookPeers: [
_peer(id: '123456789', alias: 'Office PC', username: 'ab-user'),
],
lanPeers: [
_peer(id: '123456789', username: 'lan-user', online: true),
],
);
expect(peers, hasLength(1));
expect(peers.single.id, '123456789');
expect(peers.single.alias, 'Office PC');
expect(peers.single.username, 'ab-user');
expect(peers.single.online, isTrue);
});
test('peer copies preserve online state', () {
final peer = _peer(id: '987654321', online: true);
expect(Peer.copy(peer).online, isTrue);
});
test('online callbacks update autocomplete-only peers', () {
final peers = mergeAutocompletePeers(restRecentPeerIds: ['112233445']);
final changed = updateAutocompletePeerOnlineStates(
peers,
onlines: {'112233445'},
offlines: {},
);
expect(changed, isTrue);
expect(peers.single.online, isTrue);
});
test('online query ids are deduplicated and limited', () {
final peers = List.generate(
25,
(index) => _peer(id: index.toString()),
)..insert(1, _peer(id: '0'));
final ids = autocompleteOnlineQueryIds(peers, limit: 20);
expect(ids, hasLength(20));
expect(ids.first, '0');
expect(ids.where((id) => id == '0'), hasLength(1));
expect(ids.last, '19');
});
test('empty online query ids cancel pending debounce', () async {
final queriedIds = <List<String>>[];
final loader = AllPeersLoader(
queryOnlines: (ids) async {
queriedIds.add(ids);
},
queryOnlineDebounce: Duration(milliseconds: 1),
);
loader.queryOnlines([_peer(id: '123456789')]);
loader.queryOnlines([]);
await Future.delayed(Duration(milliseconds: 2));
expect(queriedIds, isEmpty);
});
test('failed online query enqueue does not suppress retry', () async {
var queryCount = 0;
final loader = AllPeersLoader(
queryOnlines: (ids) {
queryCount += 1;
return Future<void>.error(Exception('queue full'));
},
queryOnlineDebounce: Duration(milliseconds: 1),
);
loader.queryOnlines([_peer(id: '123456789')]);
await Future.delayed(Duration(milliseconds: 2));
loader.queryOnlines([_peer(id: '123456789')]);
await Future.delayed(Duration(milliseconds: 2));
expect(queryCount, 2);
});
test('online callback updates currently displayed options', () async {
final loader = AllPeersLoader(
queryOnlines: (ids) async {},
queryOnlineDebounce: Duration(milliseconds: 1),
);
final displayedOptions = [_peer(id: '123456789')];
loader.queryOnlines(displayedOptions);
loader.updateOnlineStateForTesting({
'onlines': '123456789',
'offlines': '',
});
expect(displayedOptions.single.online, isTrue);
await Future.delayed(Duration(milliseconds: 2));
});
test('cached online callback state is reapplied after peers merge', () {
final loader = AllPeersLoader();
loader.updateOnlineStateForTesting({
'onlines': '123456789',
'offlines': '',
});
final mergedPeers = [_peer(id: '123456789')];
loader.applyLastOnlineStateForTesting(mergedPeers);
expect(mergedPeers.single.online, isTrue);
});
}

View File

@@ -1,63 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart';
final testClients = [
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false),
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false),
Client(2, false, false, false, "UserC", "331123123", true, false, false),
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false,
false)
];
/// flutter run -d {platform} -t test/cm_demo.dart to test cm
void main() async {
isTest = true;
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
await windowManager.setSize(const Size(400, 600));
await windowManager.setAlignment(Alignment.topRight);
await initEnv(kAppTypeMain);
for (var client in testClients) {
gFFI.serverModel.clients.add(client);
gFFI.serverModel.tabController.add(TabInfo(
key: client.id.toString(),
label: client.name,
closable: false,
page: buildConnectionCard(client)));
}
runApp(GetMaterialApp(
debugShowCheckedModeBanner: false,
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: supportedLocales,
home: const DesktopServerPage()));
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
// ensure initial window size to be changed
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
await Future.wait([
windowManager.setAlignment(Alignment.topRight),
windowManager.focus(),
windowManager.setOpacity(1)
]);
// ensure
windowManager.setAlignment(Alignment.topRight);
});
}

View File

@@ -1,20 +1,62 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/main.dart';
import 'package:flutter_hbb/models/server_model.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart';
import 'cm_demo.dart' as cm_demo; final testClients = [
Client(0, false, false, false, "UserAAAAAA", "123123123", true, false, false, false),
Client(1, false, false, false, "UserBBBBB", "221123123", true, false, false, false),
Client(2, false, false, false, "UserC", "331123123", true, false, false, false),
Client(3, false, false, false, "UserDDDDDDDDDDDd", "441123123", true, false, false, false)
];
void main() { /// flutter run -d {platform} -t test/cm_test.dart to test cm
test('connection manager demo clients match the current Client API', () { void main(List<String> args) async {
expect(cm_demo.testClients, hasLength(4)); isTest = true;
expect(cm_demo.testClients.map((client) => client.name), [ WidgetsFlutterBinding.ensureInitialized();
'UserAAAAAA', await windowManager.ensureInitialized();
'UserBBBBB', await windowManager.setSize(const Size(400, 600));
'UserC', await windowManager.setAlignment(Alignment.topRight);
'UserDDDDDDDDDDDd', await initEnv(kAppTypeMain);
for (var client in testClients) {
gFFI.serverModel.clients.add(client);
gFFI.serverModel.tabController.add(TabInfo(
key: client.id.toString(),
label: client.name,
closable: false,
page: buildConnectionCard(client)));
}
runApp(GetMaterialApp(
debugShowCheckedModeBanner: false,
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: supportedLocales,
home: const DesktopServerPage()));
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
// ensure initial window size to be changed
await windowManager.setSize(kConnectionManagerWindowSizeClosedChat);
await Future.wait([
windowManager.setAlignment(Alignment.topRight),
windowManager.focus(),
windowManager.setOpacity(1)
]); ]);
expect( // ensure
cm_demo.testClients.every( windowManager.setAlignment(Alignment.topRight);
(client) => client.keyboard && !client.clipboard && !client.audio),
isTrue,
);
}); });
} }

View File

@@ -1,38 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
void main() {
testWidgets('server settings text fields preserve literal input',
(tester) async {
final controller = TextEditingController(text: 'AbCdR1c1E=');
addTearDown(controller.dispose);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: serverSettingsTextFormField(
label: 'Key',
controller: controller,
errorMsg: '',
autofocus: true,
),
),
));
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller, controller);
expect(textField.autofocus, isTrue);
expect(textField.keyboardType, TextInputType.visiblePassword);
expect(textField.textCapitalization, TextCapitalization.none);
expect(textField.autocorrect, isFalse);
expect(textField.enableSuggestions, isFalse);
expect(textField.smartDashesType, SmartDashesType.disabled);
expect(textField.smartQuotesType, SmartQuotesType.disabled);
expect(textField.enableIMEPersonalizedLearning, isFalse);
expect(
textField.spellCheckConfiguration,
const SpellCheckConfiguration.disabled(),
);
});
}

View File

@@ -30,6 +30,7 @@ use uuid::Uuid;
use crate::{ use crate::{
check_port, check_port,
common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP}, common::input::{MOUSE_BUTTON_LEFT, MOUSE_BUTTON_RIGHT, MOUSE_TYPE_DOWN, MOUSE_TYPE_UP},
common::PLATFORM_ADDITION_IS_LOGIN_SCREEN,
create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported,
kcp_stream::KcpStream, kcp_stream::KcpStream,
secure_tcp, secure_tcp,
@@ -96,8 +97,6 @@ pub mod screenshot;
pub const MILLI1: Duration = Duration::from_millis(1); pub const MILLI1: Duration = Duration::from_millis(1);
pub const SEC30: Duration = Duration::from_secs(30); pub const SEC30: Duration = Duration::from_secs(30);
// Empirical restart reconnect grace window.
const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60);
pub const VIDEO_QUEUE_SIZE: usize = 120; pub const VIDEO_QUEUE_SIZE: usize = 120;
const MAX_DECODE_FAIL_COUNTER: usize = 3; const MAX_DECODE_FAIL_COUNTER: usize = 3;
@@ -1742,10 +1741,7 @@ pub struct LoginConfigHandler {
features: Option<Features>, features: Option<Features>,
pub session_id: u64, // used for local <-> server communication pub session_id: u64, // used for local <-> server communication
pub supported_encoding: SupportedEncoding, pub supported_encoding: SupportedEncoding,
restarting_remote_device: bool, pub restarting_remote_device: bool,
// Start time of the restart grace window. On Windows the peer may briefly
// reconnect before the real reboot disconnect.
restart_remote_device_at: Option<Instant>,
pub force_relay: bool, pub force_relay: bool,
pub direct: Option<bool>, pub direct: Option<bool>,
pub received: bool, pub received: bool,
@@ -1854,7 +1850,7 @@ impl LoginConfigHandler {
} }
self.session_id = sid; self.session_id = sid;
self.supported_encoding = Default::default(); self.supported_encoding = Default::default();
self.clear_restarting_remote_device(); self.restarting_remote_device = false;
self.force_relay = self.force_relay =
config::option2bool("force-always-relay", &self.get_option("force-always-relay")) config::option2bool("force-always-relay", &self.get_option("force-always-relay"))
|| force_relay || force_relay
@@ -1906,11 +1902,11 @@ impl LoginConfigHandler {
/// Check if the client should auto login. /// Check if the client should auto login.
/// Return password if the client should auto login, otherwise return empty string. /// Return password if the client should auto login, otherwise return empty string.
pub fn should_auto_login(&self) -> String { pub fn should_auto_login(&self, pi: &PeerInfo) -> String {
let l = self.lock_after_session_end.v; let l = self.lock_after_session_end.v;
let a = !self.get_option("auto-login").is_empty(); let a = !self.get_option("auto-login").is_empty();
let p = self.get_option("os-password"); let p = self.get_option("os-password");
if !p.is_empty() && l && a { if !p.is_empty() && l && a && !peer_reports_unlocked_desktop(pi) {
p p
} else { } else {
"".to_owned() "".to_owned()
@@ -2784,30 +2780,6 @@ impl LoginConfigHandler {
msg_out msg_out
} }
pub fn mark_restarting_remote_device(&mut self) {
self.restarting_remote_device = true;
self.restart_remote_device_at = Some(Instant::now());
}
pub fn clear_restarting_remote_device(&mut self) {
self.restarting_remote_device = false;
self.restart_remote_device_at = None;
}
pub fn is_restarting_remote_device(&self) -> bool {
if !self.restarting_remote_device {
return false;
}
// Keep this flag alive for a short grace window instead of clearing it on
// connection_ready or the first peer bytes. During OS restart the peer can
// briefly reconnect before the real reboot disconnect, and clearing it too
// early would let the next disconnect escape the restart flow and fall back
// to the normal error dialog / manual reconnect path.
self.restart_remote_device_at
.map(|started_at| started_at.elapsed() < RESTART_REMOTE_DEVICE_GRACE)
.unwrap_or(false)
}
pub fn get_conn_token(&self) -> Option<String> { pub fn get_conn_token(&self) -> Option<String> {
if self.password.is_empty() { if self.password.is_empty() {
return None; return None;
@@ -2833,6 +2805,67 @@ impl LoginConfigHandler {
} }
} }
fn peer_reports_unlocked_desktop(pi: &PeerInfo) -> bool {
serde_json::from_str::<HashMap<String, serde_json::Value>>(&pi.platform_additions)
.ok()
.and_then(|platform_additions| {
platform_additions
.get(PLATFORM_ADDITION_IS_LOGIN_SCREEN)
.and_then(|value| value.as_bool())
})
== Some(false)
}
#[cfg(test)]
mod tests {
use hbb_common::message_proto::PeerInfo;
fn login_config_handler() -> super::LoginConfigHandler {
let mut handler = super::LoginConfigHandler::default();
handler.config.lock_after_session_end.v = true;
handler
.config
.options
.insert("auto-login".to_owned(), "Y".to_owned());
handler
.config
.options
.insert("os-password".to_owned(), "secret".to_owned());
handler
}
fn peer_info(platform_additions: &str) -> PeerInfo {
PeerInfo {
platform_additions: platform_additions.to_owned(),
..Default::default()
}
}
#[test]
fn should_auto_login_skips_unlocked_peer() {
let handler = login_config_handler();
let pi = peer_info(r#"{"is_login_screen":false}"#);
assert_eq!("", handler.should_auto_login(&pi));
}
#[test]
fn should_auto_login_keeps_peer_on_login_screen() {
let handler = login_config_handler();
let pi = peer_info(r#"{"is_login_screen":true}"#);
assert_eq!("secret", handler.should_auto_login(&pi));
}
#[test]
fn should_auto_login_keeps_legacy_peer_without_login_screen_state() {
let handler = login_config_handler();
let pi = peer_info("");
assert_eq!("secret", handler.should_auto_login(&pi));
}
}
/// Media data. /// Media data.
pub enum MediaData { pub enum MediaData {
VideoQueue, VideoQueue,
@@ -3747,18 +3780,9 @@ pub trait Interface: Send + Clone + 'static + Sized {
fn on_establish_connection_error(&self, err: String) { fn on_establish_connection_error(&self, err: String) {
let title = "Connection Error"; let title = "Connection Error";
let text = err.to_string(); let text = err.to_string();
let lch = self.get_lch(); let lc = self.get_lch();
let (is_restarting, direct, received) = { let direct = lc.read().unwrap().direct;
let lc = lch.read().unwrap(); let received = lc.read().unwrap().received;
(lc.is_restarting_remote_device(), lc.direct, lc.received)
};
if is_restarting {
log::info!("Restart remote device, suppress connection error: {err}");
// Flutter treats this as a reconnect control event. The text is kept
// for legacy UI and existing translation reuse.
self.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
return;
}
let mut relay_hint = false; let mut relay_hint = false;
let mut relay_hint_type = "relay-hint"; let mut relay_hint_type = "relay-hint";

View File

@@ -10,10 +10,6 @@ use crate::{
common::get_default_sound_input, common::get_default_sound_input,
ui_session_interface::{InvokeUiSession, Session}, ui_session_interface::{InvokeUiSession, Session},
}; };
// Empirical no-data window before exposing the restart reconnect state to the UI.
// Restart msgbox text is kept as a legacy UI fallback; Flutter handles the type as a control event.
const RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(feature = "unix-file-copy-paste")] #[cfg(feature = "unix-file-copy-paste")]
use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip}; use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip};
#[cfg(any( #[cfg(any(
@@ -157,6 +153,7 @@ impl<T: InvokeUiSession> Remote<T> {
} }
}; };
let mut last_recv_time = Instant::now();
let mut received = false; let mut received = false;
let conn_type = if self.handler.is_file_transfer() { let conn_type = if self.handler.is_file_transfer() {
ConnType::FILE_TRANSFER ConnType::FILE_TRANSFER
@@ -222,7 +219,6 @@ impl<T: InvokeUiSession> Remote<T> {
let mut fps_instant = Instant::now(); let mut fps_instant = Instant::now();
let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await; let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await;
let mut last_recv_time = Instant::now();
loop { loop {
tokio::select! { tokio::select! {
@@ -248,7 +244,7 @@ impl<T: InvokeUiSession> Remote<T> {
} else { } else {
if self.handler.is_restarting_remote_device() { if self.handler.is_restarting_remote_device() {
log::info!("Restart remote device"); log::info!("Restart remote device");
self.handler.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", ""); self.handler.msgbox("restarting", "Restarting remote device", "remote_restarting_tip", "");
} else { } else {
log::info!("Reset by the peer"); log::info!("Reset by the peer");
self.handler.msgbox("error", "Connection Error", "Reset by the peer", ""); self.handler.msgbox("error", "Connection Error", "Reset by the peer", "");
@@ -283,12 +279,6 @@ impl<T: InvokeUiSession> Remote<T> {
} }
} }
_ = status_timer.tick() => { _ = status_timer.tick() => {
if self.handler.is_restarting_remote_device()
&& last_recv_time.elapsed() >= RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT
{
self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", "");
break;
}
let elapsed = fps_instant.elapsed().as_millis(); let elapsed = fps_instant.elapsed().as_millis();
if elapsed < 1000 { if elapsed < 1000 {
continue; continue;

View File

@@ -59,6 +59,7 @@ pub const PLATFORM_WINDOWS: &str = "Windows";
pub const PLATFORM_LINUX: &str = "Linux"; pub const PLATFORM_LINUX: &str = "Linux";
pub const PLATFORM_MACOS: &str = "Mac OS"; pub const PLATFORM_MACOS: &str = "Mac OS";
pub const PLATFORM_ANDROID: &str = "Android"; pub const PLATFORM_ANDROID: &str = "Android";
pub const PLATFORM_ADDITION_IS_LOGIN_SCREEN: &str = "is_login_screen";
pub const TIMER_OUT: Duration = Duration::from_secs(1); pub const TIMER_OUT: Duration = Duration::from_secs(1);
pub const DEFAULT_KEEP_ALIVE: i32 = 60_000; pub const DEFAULT_KEEP_ALIVE: i32 = 60_000;

View File

@@ -430,7 +430,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Strong", "Forte"), ("Strong", "Forte"),
("Switch Sides", "Trocar de lado"), ("Switch Sides", "Trocar de lado"),
("Please confirm if you want to share your desktop?", "Por favor, confirme se você deseja compartilhar sua área de trabalho?"), ("Please confirm if you want to share your desktop?", "Por favor, confirme se você deseja compartilhar sua área de trabalho?"),
("Display", "Exibição"), ("Display", "Display"),
("Default View Style", "Estilo de Visualização Padrão"), ("Default View Style", "Estilo de Visualização Padrão"),
("Default Scroll Style", "Estilo de Rolagem Padrão"), ("Default Scroll Style", "Estilo de Rolagem Padrão"),
("Default Image Quality", "Qualidade de Imagem Padrão"), ("Default Image Quality", "Qualidade de Imagem Padrão"),
@@ -744,7 +744,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("password-hidden-tip", "A senha permanente está definida como (oculta)."), ("password-hidden-tip", "A senha permanente está definida como (oculta)."),
("preset-password-in-use-tip", "A senha predefinida está sendo usada."), ("preset-password-in-use-tip", "A senha predefinida está sendo usada."),
("Enable privacy mode", "Habilitar modo de privacidade"), ("Enable privacy mode", "Habilitar modo de privacidade"),
("allow-remote-toolbar-docking-any-edge", "Fixar a barra de ferramentas remota em qualquer borda da janela"), ("allow-remote-toolbar-docking-any-edge", "Permitir fixar a barra de ferramentas remota em qualquer borda da janela"),
("API Token", "Token de API"), ("API Token", "Token de API"),
("Deploy", "Implantar"), ("Deploy", "Implantar"),
("Custom ID (optional)", "ID personalizado (opcional)"), ("Custom ID (optional)", "ID personalizado (opcional)"),

View File

@@ -1623,6 +1623,10 @@ impl Connection {
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
platform_additions.insert(
crate::common::PLATFORM_ADDITION_IS_LOGIN_SCREEN.into(),
json!(crate::platform::is_prelogin() || crate::platform::is_locked()),
);
platform_additions.insert( platform_additions.insert(
"supported_privacy_mode_impl".into(), "supported_privacy_mode_impl".into(),
json!(privacy_mode::get_supported_privacy_mode_impl()), json!(privacy_mode::get_supported_privacy_mode_impl()),

View File

@@ -560,7 +560,7 @@ impl<T: InvokeUiSession> Session<T> {
pub fn restart_remote_device(&self) { pub fn restart_remote_device(&self) {
let mut lc = self.lc.write().unwrap(); let mut lc = self.lc.write().unwrap();
lc.mark_restarting_remote_device(); lc.restarting_remote_device = true;
let msg = lc.restart_remote_device(); let msg = lc.restart_remote_device();
self.send(Data::Message(msg)); self.send(Data::Message(msg));
} }
@@ -656,7 +656,7 @@ impl<T: InvokeUiSession> Session<T> {
} }
pub fn is_restarting_remote_device(&self) -> bool { pub fn is_restarting_remote_device(&self) -> bool {
self.lc.read().unwrap().is_restarting_remote_device() self.lc.read().unwrap().restarting_remote_device
} }
#[inline] #[inline]
@@ -1806,7 +1806,7 @@ impl<T: InvokeUiSession> Interface for Session<T> {
return; return;
} }
self.try_change_init_resolution(pi.current_display); self.try_change_init_resolution(pi.current_display);
let p = self.lc.read().unwrap().should_auto_login(); let p = self.lc.read().unwrap().should_auto_login(&pi);
if !p.is_empty() { if !p.is_empty() {
input_os_password(p, true, self.clone()); input_os_password(p, true, self.clone());
} }