Compare commits

...

10 Commits

Author SHA1 Message Date
rustdesk
0a0625126e fix https://github.com/rustdesk/rustdesk/issues/15326 2026-06-18 12:09:51 +08:00
Maison da Silva
8a955888bf Fix Portuguese translations for consistency (#15325)
Fix Portuguese translations for consistency
2026-06-18 10:28:34 +08:00
21pages
36e812e550 update hwcodec (#15323)
Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-06-17 22:58:46 +08:00
rustdesk
8baa995c7a bump version 2026-06-17 22:18:45 +08:00
RustDesk
f4a0535289 autocomplete online (#15313)
* autocomplete online

* review fix

* review fix

* remove literalInput
2026-06-17 22:04:34 +08:00
RustDesk
6665242edf Revert "refact: privacy mdoe 1, multi-monitors (#15318)" (#15320)
This reverts commit 3cdf1cce54.
2026-06-17 21:46:40 +08:00
fufesou
3cdf1cce54 refact: privacy mdoe 1, multi-monitors (#15318)
* refact: privacy mdoe 1, multi-monitors

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: harden privacy mode overlay & capture cleanup

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-17 21:43:57 +08:00
fufesou
88ae00ba73 refact: restart remote device, autoconnect (#15290)
* refact: restart remote device, autoconnect

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix: guard restart reconnect timer after session close

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(restart): auto connect, comments

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-17 17:50:18 +08:00
Maison da Silva
7c26575dbd Update translation for remote toolbar docking message (#15297)
* Update translation for remote toolbar docking message

Update translation for remote toolbar docking message

* Translate 'Display' to 'Tela' in Portuguese locale

* Change translation of 'Display' to 'Exibição'
2026-06-17 17:36:27 +08:00
fufesou
93d064a9b0 refact(oidc): icon azure to microsoft (#15278)
* refact(oidc): icon azure to microsoft

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact: oidc, remove unused auth-azure.svg

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-06-17 14:36:53 +08:00
29 changed files with 768 additions and 177 deletions

View File

@@ -39,7 +39,7 @@ env:
# 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`.
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version
VERSION: "1.4.7" VERSION: "1.4.8"
NDK_VERSION: "r28c" NDK_VERSION: "r28c"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

View File

@@ -17,7 +17,7 @@ env:
TAG_NAME: "nightly" TAG_NAME: "nightly"
VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite"
VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b"
VERSION: "1.4.7" VERSION: "1.4.8"
NDK_VERSION: "r26d" NDK_VERSION: "r26d"
#signing keys env variable checks #signing keys env variable checks
ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}"

6
Cargo.lock generated
View File

@@ -3952,7 +3952,7 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]] [[package]]
name = "hwcodec" name = "hwcodec"
version = "0.7.1" version = "0.7.1"
source = "git+https://github.com/rustdesk-org/hwcodec#398e5a8938dd8768ade0fcdc27ea80e8b4b38738" source = "git+https://github.com/rustdesk-org/hwcodec#778df1f99597722473b29443bac22ae6c23946fe"
dependencies = [ dependencies = [
"bindgen 0.59.2", "bindgen 0.59.2",
"cc", "cc",
@@ -7270,7 +7270,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk" name = "rustdesk"
version = "1.4.7" version = "1.4.8"
dependencies = [ dependencies = [
"android-wakelock", "android-wakelock",
"android_logger", "android_logger",
@@ -7385,7 +7385,7 @@ dependencies = [
[[package]] [[package]]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.4.7" version = "1.4.8"
dependencies = [ dependencies = [
"brotli", "brotli",
"dirs 5.0.1", "dirs 5.0.1",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk" name = "rustdesk"
version = "1.4.7" version = "1.4.8"
authors = ["rustdesk <info@rustdesk.com>"] authors = ["rustdesk <info@rustdesk.com>"]
edition = "2021" edition = "2021"
build= "build.rs" build= "build.rs"

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.4.7 version: 1.4.8
exec: usr/share/rustdesk/rustdesk exec: usr/share/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:

View File

@@ -18,7 +18,7 @@ AppDir:
id: rustdesk id: rustdesk
name: rustdesk name: rustdesk
icon: rustdesk icon: rustdesk
version: 1.4.7 version: 1.4.8
exec: usr/share/rustdesk/rustdesk exec: usr/share/rustdesk/rustdesk
exec_args: $@ exec_args: $@
apt: apt:

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 604 B

View File

@@ -1,3 +1,6 @@
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';
@@ -5,27 +8,136 @@ 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';
late void Function(VoidCallback) setState; static const Duration _queryOnlineInterval = Duration(seconds: 5);
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) {
this.setState = setState; _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() {
@@ -33,6 +145,11 @@ 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 {
@@ -59,50 +176,106 @@ class AllPeersLoader {
} }
void _mergeAllPeers() { void _mergeAllPeers() {
Map<String, dynamic> combinedPeers = {}; if (_isCleared) {
for (var p in gFFI.abModel.allPeers()) { return;
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
} }
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) { peers = mergeAutocompletePeers(
if (!combinedPeers.containsKey(p.id)) { addressBookPeers: gFFI.abModel.allPeers(),
combinedPeers[p.id] = p.toJson(); groupPeers: gFFI.groupModel.peers,
} lanPeers: gFFI.lanPeersModel.peers,
} recentPeers: gFFI.recentPeersModel.peers,
restRecentPeerIds: gFFI.recentPeersModel.restPeerIds,
List<Peer> parsedPeers = []; );
for (var peer in combinedPeers.values) { _applyLastOnlineState(peers);
parsedPeers.add(Peer.fromJson(peer)); _scheduleSetState(() {
}
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,6 +24,35 @@ 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;
@@ -74,11 +103,8 @@ class ButtonOP extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final opLabel = { final branding = _oidcProviderBranding(op);
'github': 'GitHub', final buttonLabel = translate("Continue with {${branding.label}}");
'gitlab': 'GitLab'
}[op.toLowerCase()] ??
toCapitalized(op);
return Row(children: [ return Row(children: [
Container( Container(
height: height, height: height,
@@ -95,7 +121,7 @@ class ButtonOP extends StatelessWidget {
SizedBox( SizedBox(
width: 30, width: 30,
child: _IconOP( child: _IconOP(
op: op, op: branding.iconKey,
icon: icon, icon: icon,
margin: EdgeInsets.only(right: 5), margin: EdgeInsets.only(right: 5),
), ),
@@ -103,8 +129,7 @@ class ButtonOP extends StatelessWidget {
Expanded( Expanded(
child: FittedBox( child: FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: Center( child: Center(child: Text(buttonLabel)),
child: Text(translate("Continue with {$opLabel}"))),
), ),
), ),
], ],

View File

@@ -398,6 +398,7 @@ 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,6 +207,7 @@ 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,6 +55,8 @@ 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 = {};
@@ -119,6 +121,7 @@ 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;
@@ -250,6 +253,7 @@ 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();
@@ -341,6 +345,7 @@ 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);
@@ -922,8 +927,28 @@ 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') {
showMsgBox(sessionId, type, title, text, link, false, dialogManager, // Treat restart messages as reconnect control events. Rust still sends
hasCancel: false); // title/text for legacy UI and translation reuse; Flutter keeps the last
// 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') {
@@ -949,6 +974,11 @@ 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(
@@ -1374,6 +1404,7 @@ 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;
} }
@@ -3666,6 +3697,7 @@ 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;
@@ -3879,6 +3911,7 @@ 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,23 +145,26 @@ class Peer {
note == other.note; note == other.note;
} }
Peer.copy(Peer other) factory Peer.copy(Peer other) {
: this( final peer = Peer(
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

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers
version: 1.4.7+65 version: 1.4.8+66
environment: environment:
sdk: '^3.1.0' sdk: '^3.1.0'

View File

@@ -0,0 +1,148 @@
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);
});
}

63
flutter/test/cm_demo.dart Normal file
View File

@@ -0,0 +1,63 @@
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,62 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.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 = [ import 'cm_demo.dart' as cm_demo;
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)
];
/// flutter run -d {platform} -t test/cm_test.dart to test cm void main() {
void main(List<String> args) async { test('connection manager demo clients match the current Client API', () {
isTest = true; expect(cm_demo.testClients, hasLength(4));
WidgetsFlutterBinding.ensureInitialized(); expect(cm_demo.testClients.map((client) => client.name), [
await windowManager.ensureInitialized(); 'UserAAAAAA',
await windowManager.setSize(const Size(400, 600)); 'UserBBBBB',
await windowManager.setAlignment(Alignment.topRight); 'UserC',
await initEnv(kAppTypeMain); 'UserDDDDDDDDDDDd',
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 expect(
windowManager.setAlignment(Alignment.topRight); cm_demo.testClients.every(
(client) => client.keyboard && !client.clipboard && !client.audio),
isTrue,
);
}); });
} }

View File

@@ -0,0 +1,38 @@
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

@@ -7,7 +7,8 @@ use fuser::MountOption;
use hbb_common::{config::APP_NAME, log}; use hbb_common::{config::APP_NAME, log};
use parking_lot::Mutex; use parking_lot::Mutex;
use std::{ use std::{
path::PathBuf, io,
path::{Path, PathBuf},
sync::{mpsc::Sender, Arc}, sync::{mpsc::Sender, Arc},
time::Duration, time::Duration,
}; };
@@ -53,8 +54,16 @@ pub fn init_fuse_context(is_client: bool) -> Result<(), CliprdrError> {
} else { } else {
FUSE_CONTEXT_SERVER.lock() FUSE_CONTEXT_SERVER.lock()
}; };
if fuse_context_lock.is_some() { if let Some(ctx) = fuse_context_lock.as_ref() {
return Ok(()); if is_mount_point_healthy(&ctx.mount_point) {
return Ok(());
}
log::warn!(
"clipboard FUSE mount {} is disconnected, remounting",
ctx.mount_point.display()
);
let stale_context = fuse_context_lock.take();
drop(stale_context);
} }
let mount_point = if is_client { let mount_point = if is_client {
FUSE_MOUNT_POINT_CLIENT.clone() FUSE_MOUNT_POINT_CLIENT.clone()
@@ -159,35 +168,100 @@ struct FuseContext {
} }
// this function must be called after the main IPC is up // this function must be called after the main IPC is up
fn prepare_fuse_mount_point(mount_point: &PathBuf) { fn prepare_fuse_mount_point(mount_point: &Path) {
use std::{ use std::{
fs::{self, Permissions}, fs::{self, Permissions},
os::unix::prelude::PermissionsExt, os::unix::prelude::PermissionsExt,
}; };
fs::create_dir(mount_point).ok(); if let Some(parent) = mount_point.parent() {
fs::set_permissions(mount_point, Permissions::from_mode(0o777)).ok(); if let Err(e) = fs::create_dir_all(parent) {
log::warn!("failed to create FUSE mount parent {:?}: {:?}", parent, e);
}
}
if let Err(e) = std::process::Command::new("umount") unmount_fuse_mount_point(mount_point);
.arg(mount_point)
.status() if let Err(e) = fs::create_dir_all(mount_point) {
{ log::warn!(
log::warn!("umount {:?} may fail: {:?}", mount_point, e); "failed to create FUSE mount point {:?}: {:?}",
mount_point,
e
);
}
if let Err(e) = fs::set_permissions(mount_point, Permissions::from_mode(0o777)) {
log::warn!(
"failed to set FUSE mount point permissions {:?}: {:?}",
mount_point,
e
);
} }
} }
fn uninit_fuse_context_(is_client: bool) { fn is_mount_point_healthy(mount_point: &Path) -> bool {
if is_client { is_mount_point_healthy_result(std::fs::metadata(mount_point))
let _ = FUSE_CONTEXT_CLIENT.lock().take(); }
} else {
let _ = FUSE_CONTEXT_SERVER.lock().take(); fn is_mount_point_healthy_result<T>(result: io::Result<T>) -> bool {
match result {
Ok(_) => true,
Err(e) => {
e.raw_os_error() != Some(libc::ENOTCONN) && e.kind() != io::ErrorKind::NotFound
}
} }
} }
fn unmount_fuse_mount_point(mount_point: &Path) {
if run_unmount_command("umount", &["-l"], mount_point) {
return;
}
if run_unmount_command("fusermount3", &["-uz"], mount_point) {
return;
}
run_unmount_command("fusermount", &["-uz"], mount_point);
}
fn run_unmount_command(program: &str, args: &[&str], mount_point: &Path) -> bool {
match std::process::Command::new(program)
.args(args)
.arg(mount_point)
.status()
{
Ok(status) if status.success() => {}
Ok(status) => {
log::debug!(
"{} {:?} exited with status {:?}",
program,
mount_point,
status.code()
);
return false;
}
Err(e) => {
log::debug!("failed to run {} for {:?}: {:?}", program, mount_point, e);
return false;
}
}
true
}
fn uninit_fuse_context_(is_client: bool) {
let mut fuse_context_lock = if is_client {
FUSE_CONTEXT_CLIENT.lock()
} else {
FUSE_CONTEXT_SERVER.lock()
};
let ctx = fuse_context_lock.take();
drop(ctx);
}
impl Drop for FuseContext { impl Drop for FuseContext {
fn drop(&mut self) { fn drop(&mut self) {
self.session.lock().take().map(|s| s.join());
log::info!("unmounting clipboard FUSE from {}", self.mount_point.display()); log::info!("unmounting clipboard FUSE from {}", self.mount_point.display());
unmount_fuse_mount_point(&self.mount_point);
if let Some(session) = self.session.lock().take() {
session.join();
}
} }
} }
@@ -223,3 +297,30 @@ impl FuseContext {
.collect()) .collect())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::{fs, io};
#[test]
fn reports_disconnected_fuse_mount_as_unhealthy() {
let err = io::Error::from_raw_os_error(libc::ENOTCONN);
assert!(!is_mount_point_healthy_result::<()>(Err(err)));
}
#[test]
fn reports_existing_directory_mount_point_as_healthy() {
let mount_point = std::env::temp_dir().join(format!(
"rustdesk-fuse-mount-health-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&mount_point);
fs::create_dir(&mount_point).unwrap();
assert!(is_mount_point_healthy(&mount_point));
let _ = fs::remove_dir_all(&mount_point);
}
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rustdesk-portable-packer" name = "rustdesk-portable-packer"
version = "1.4.7" version = "1.4.8"
edition = "2021" edition = "2021"
description = "RustDesk Remote Desktop" description = "RustDesk Remote Desktop"

View File

@@ -1,5 +1,5 @@
pkgname=rustdesk pkgname=rustdesk
pkgver=1.4.7 pkgver=1.4.8
pkgrel=0 pkgrel=0
epoch= epoch=
pkgdesc="" pkgdesc=""

View File

@@ -1,3 +1,3 @@
#! /usr/bin/env bash #! /usr/bin/env bash
sed -i "s/$1/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml sed -i "s/\b$1\b/$2/g" res/*spec res/PKGBUILD flutter/pubspec.yaml Cargo.toml .github/workflows/*yml flatpak/*json appimage/*yml libs/portable/Cargo.toml
cargo run # to bump version in cargo lock cargo run # to bump version in cargo lock

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.4.7 Version: 1.4.8
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.4.7 Version: 1.4.8
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -1,5 +1,5 @@
Name: rustdesk Name: rustdesk
Version: 1.4.7 Version: 1.4.8
Release: 0 Release: 0
Summary: RPM package Summary: RPM package
License: GPL-3.0 License: GPL-3.0

View File

@@ -96,6 +96,8 @@ 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;
@@ -1740,7 +1742,10 @@ 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,
pub restarting_remote_device: bool, 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,
@@ -1849,7 +1854,7 @@ impl LoginConfigHandler {
} }
self.session_id = sid; self.session_id = sid;
self.supported_encoding = Default::default(); self.supported_encoding = Default::default();
self.restarting_remote_device = false; self.clear_restarting_remote_device();
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
@@ -2779,6 +2784,30 @@ 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;
@@ -3718,9 +3747,18 @@ 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 lc = self.get_lch(); let lch = self.get_lch();
let direct = lc.read().unwrap().direct; let (is_restarting, direct, received) = {
let received = lc.read().unwrap().received; let lc = lch.read().unwrap();
(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,6 +10,10 @@ 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(
@@ -153,7 +157,6 @@ 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
@@ -219,6 +222,7 @@ 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! {
@@ -244,7 +248,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", "remote_restarting_tip", ""); self.handler.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", "");
} 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", "");
@@ -279,6 +283,12 @@ 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

@@ -24,9 +24,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Refresh random password", "Atualizar senha aleatória"), ("Refresh random password", "Atualizar senha aleatória"),
("Set your own password", "Configure sua própria senha"), ("Set your own password", "Configure sua própria senha"),
("Enable keyboard/mouse", "Habilitar teclado/mouse"), ("Enable keyboard/mouse", "Habilitar teclado/mouse"),
("Enable clipboard", "Habilitar Área de Transferência"), ("Enable clipboard", "Habilitar área de transferência"),
("Enable file transfer", "Habilitar Transferência de Arquivos"), ("Enable file transfer", "Habilitar transferência de arquivos"),
("Enable TCP tunneling", "Habilitar Tunelamento TCP"), ("Enable TCP tunneling", "Habilitar tunelamento TCP"),
("IP Whitelisting", "Lista de IPs Confiáveis"), ("IP Whitelisting", "Lista de IPs Confiáveis"),
("ID/Relay Server", "Servidor ID/Relay"), ("ID/Relay Server", "Servidor ID/Relay"),
("Import server config", "Importar Configuração do Servidor"), ("Import server config", "Importar Configuração do Servidor"),
@@ -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", "Display"), ("Display", "Exibição"),
("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"),
@@ -693,11 +693,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Enable IPv6 P2P connection", "Habilitar conexão IPv6 P2P"), ("Enable IPv6 P2P connection", "Habilitar conexão IPv6 P2P"),
("Enable UDP hole punching", "Habilitar UDP hole punching"), ("Enable UDP hole punching", "Habilitar UDP hole punching"),
("View camera", "Visualizar câmera"), ("View camera", "Visualizar câmera"),
("Enable camera", "Ativar câmera"), ("Enable camera", "Habilitar câmera"),
("No cameras", "Sem câmeras"), ("No cameras", "Nenhuma câmeras"),
("view_camera_unsupported_tip", "O dispositivo remoto não suporta visualização da câmera."), ("view_camera_unsupported_tip", "O dispositivo remoto não suporta visualização da câmera."),
("Terminal", "Terminal"), ("Terminal", "Terminal"),
("Enable terminal", "Habilitar Terminal"), ("Enable terminal", "Habilitar terminal"),
("New tab", "Nova aba"), ("New tab", "Nova aba"),
("Keep terminal sessions on disconnect", "Manter sessões de terminal ao desconectar"), ("Keep terminal sessions on disconnect", "Manter sessões de terminal ao desconectar"),
("Terminal (Run as administrator)", "Terminal (Executar como administrador)"), ("Terminal (Run as administrator)", "Terminal (Executar como administrador)"),
@@ -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", "Permitir fixar a barra de ferramentas remota em qualquer borda da janela"), ("allow-remote-toolbar-docking-any-edge", "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

@@ -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.restarting_remote_device = true; lc.mark_restarting_remote_device();
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().restarting_remote_device self.lc.read().unwrap().is_restarting_remote_device()
} }
#[inline] #[inline]