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
14 changed files with 184 additions and 423 deletions

View File

@@ -1,6 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import '../../../models/platform_model.dart';
@@ -8,106 +5,13 @@ import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/common.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 {
List<Peer> peers = [];
bool _isPeersLoading = false;
bool _isPeersLoaded = false;
Set<String> _lastQueryOnlineIds = {};
DateTime _lastQueryOnlineTime = DateTime.fromMillisecondsSinceEpoch(0);
Timer? _queryOnlineTimer;
final String _listenerKey = 'AllPeersLoader';
static const String _cbQueryOnlines = 'callback_query_onlines';
static const Duration _queryOnlineInterval = Duration(seconds: 5);
static const Duration _queryOnlineDebounce = Duration(milliseconds: 300);
static const int _maxQueryOnlineOptions = 20;
late void Function(VoidCallback) setState;
@@ -122,10 +26,6 @@ class AllPeersLoader {
gFFI.lanPeersModel.addListener(_mergeAllPeers);
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
platformFFI.registerEventHandler(_cbQueryOnlines, _listenerKey,
(evt) async {
_updateOnlineState(evt);
});
}
void clear() {
@@ -133,8 +33,6 @@ class AllPeersLoader {
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
gFFI.abModel.removePeerUpdateListener(_listenerKey);
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
platformFFI.unregisterEventHandler(_cbQueryOnlines, _listenerKey);
_queryOnlineTimer?.cancel();
}
Future<void> getAllPeers() async {
@@ -161,60 +59,50 @@ class AllPeersLoader {
}
void _mergeAllPeers() {
peers = mergeAutocompletePeers(
addressBookPeers: gFFI.abModel.allPeers(),
groupPeers: gFFI.groupModel.peers,
lanPeers: gFFI.lanPeersModel.peers,
recentPeers: gFFI.recentPeersModel.peers,
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
);
Map<String, dynamic> combinedPeers = {};
for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
}
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
}
List<Peer> parsedPeers = [];
for (var peer in combinedPeers.values) {
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;
_isPeersLoaded = true;
});
}
void _updateOnlineState(Map<String, dynamic> evt) {
final changed = updateAutocompletePeerOnlineStates(
peers,
onlines: _splitPeerIds(evt['onlines']),
offlines: _splitPeerIds(evt['offlines']),
);
if (changed) {
setState(() {});
}
}
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) {
final ids = autocompleteOnlineQueryIds(
options,
limit: _maxQueryOnlineOptions,
).toSet();
if (ids.isEmpty) {
return;
}
final now = DateTime.now();
if (setEquals(ids, _lastQueryOnlineIds) &&
now.difference(_lastQueryOnlineTime) < _queryOnlineInterval) {
return;
}
_queryOnlineTimer?.cancel();
_queryOnlineTimer = Timer(_queryOnlineDebounce, () {
_lastQueryOnlineIds = ids;
_lastQueryOnlineTime = DateTime.now();
bind.queryOnlines(ids: ids.toList(growable: false)).catchError((e) {
debugPrint('query autocomplete online state failed: $e');
});
});
}
}
class AutocompletePeerTile extends StatefulWidget {

View File

@@ -393,7 +393,6 @@ class DialogTextField extends StatelessWidget {
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final int? maxLength;
final bool literalInput;
static const kUsernameTitle = 'Username';
static const kUsernameIcon = Icon(Icons.account_circle_outlined);
@@ -412,7 +411,6 @@ class DialogTextField extends StatelessWidget {
this.keyboardType,
this.inputFormatters,
this.maxLength,
this.literalInput = false,
required this.title,
required this.controller})
: super(key: key);
@@ -437,17 +435,7 @@ class DialogTextField extends StatelessWidget {
focusNode: focusNode,
autofocus: true,
obscureText: obscureText,
keyboardType: keyboardType ??
(literalInput ? TextInputType.visiblePassword : null),
textCapitalization: TextCapitalization.none,
autocorrect: !literalInput,
enableSuggestions: !literalInput,
smartDashesType: literalInput ? SmartDashesType.disabled : null,
smartQuotesType: literalInput ? SmartQuotesType.disabled : null,
enableIMEPersonalizedLearning: !literalInput,
spellCheckConfiguration: literalInput
? const SpellCheckConfiguration.disabled()
: null,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLength: maxLength,
),

View File

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

View File

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

View File

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

View File

@@ -1,83 +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');
});
}

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() {
test('connection manager demo clients match the current Client API', () {
expect(cm_demo.testClients, hasLength(4));
expect(cm_demo.testClients.map((client) => client.name), [
'UserAAAAAA',
'UserBBBBB',
'UserC',
'UserDDDDDDDDDDDd',
/// flutter run -d {platform} -t test/cm_test.dart to test cm
void main(List<String> args) 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)
]);
expect(
cm_demo.testClients.every(
(client) => client.keyboard && !client.clipboard && !client.audio),
isTrue,
);
// ensure
windowManager.setAlignment(Alignment.topRight);
});
}

View File

@@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
void main() {
testWidgets('DialogTextField can preserve literal input', (tester) async {
final controller = TextEditingController(text: 'P@ss1c1E=');
addTearDown(controller.dispose);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: DialogTextField(
title: 'Password',
controller: controller,
literalInput: true,
),
),
));
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.controller, controller);
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

@@ -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::{
check_port,
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,
kcp_stream::KcpStream,
secure_tcp,
@@ -1901,11 +1902,11 @@ impl LoginConfigHandler {
/// Check if the client should auto login.
/// 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 a = !self.get_option("auto-login").is_empty();
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
} else {
"".to_owned()
@@ -2804,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.
pub enum MediaData {
VideoQueue,

View File

@@ -59,6 +59,7 @@ pub const PLATFORM_WINDOWS: &str = "Windows";
pub const PLATFORM_LINUX: &str = "Linux";
pub const PLATFORM_MACOS: &str = "Mac OS";
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 DEFAULT_KEEP_ALIVE: i32 = 60_000;

View File

@@ -1623,6 +1623,10 @@ impl Connection {
}
#[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(
"supported_privacy_mode_impl".into(),
json!(privacy_mode::get_supported_privacy_mode_impl()),

View File

@@ -1806,7 +1806,7 @@ impl<T: InvokeUiSession> Interface for Session<T> {
return;
}
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() {
input_os_password(p, true, self.clone());
}