mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-08 15:18:13 +03:00
Compare commits
33 Commits
keyboard-s
...
terminal-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdfd986cb9 | ||
|
|
e47e5b38b6 | ||
|
|
5439ec38b6 | ||
|
|
8b8a64f870 | ||
|
|
92509f8e8a | ||
|
|
0221634a4d | ||
|
|
9d1f86fbc6 | ||
|
|
f29dec7b13 | ||
|
|
d5d0b01266 | ||
|
|
5abae617dc | ||
|
|
52d62da002 | ||
|
|
253d632709 | ||
|
|
6a53757f68 | ||
|
|
5a65d45244 | ||
|
|
383a5c3478 | ||
|
|
e99829d709 | ||
|
|
071c6b1c12 | ||
|
|
26a356d0f5 | ||
|
|
929a4e78ba | ||
|
|
18479129a2 | ||
|
|
b516dfb15b | ||
|
|
d5568d9188 | ||
|
|
1745bab204 | ||
|
|
0eff404323 | ||
|
|
67b5484ded | ||
|
|
268827ef64 | ||
|
|
c4542b4a5d | ||
|
|
59f3060a04 | ||
|
|
0a1500a72a | ||
|
|
4f5c7db70a | ||
|
|
0112167029 | ||
|
|
0d77482a64 | ||
|
|
ca3ef2a1c3 |
@@ -716,6 +716,17 @@ closeConnection({String? id}) {
|
||||
stateGlobal.isInMainPage = true;
|
||||
} else {
|
||||
final controller = Get.find<DesktopTabController>();
|
||||
if (controller.tabType == DesktopTabType.terminal &&
|
||||
controller.onCloseWindow != null) {
|
||||
// Terminal windows are scoped to one peer. The optional id passed to
|
||||
// closeConnection() is that peer id, not a terminal tab key
|
||||
// (${peerId}_${terminalId}). Closing from terminal dialogs should close
|
||||
// the peer's whole terminal window, including all terminal tabs.
|
||||
unawaited(controller.onCloseWindow!().catchError((e, _) {
|
||||
debugPrint('[closeConnection] Failed to close terminal window: $e');
|
||||
}));
|
||||
return;
|
||||
}
|
||||
controller.closeBy(id);
|
||||
}
|
||||
}
|
||||
@@ -4179,8 +4190,7 @@ Widget? buildAvatarWidget({
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
fallback ?? SizedBox.shrink(),
|
||||
errorBuilder: (_, __, ___) => fallback ?? SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -759,9 +759,18 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
final ffiModel = ffi.ffiModel;
|
||||
final pi = ffiModel.pi;
|
||||
final sessionId = ffi.sessionId;
|
||||
final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false;
|
||||
|
||||
// Backend revocation already attempts to turn privacy mode off.
|
||||
// Still keep this menu when privacy mode is active, so users can turn it off
|
||||
// if there is a sync delay, version mismatch, or off attempt failure.
|
||||
if (!hasPrivacyModePermission && privacyModeState.isEmpty) {
|
||||
return []; // No permission and not active, hide options.
|
||||
}
|
||||
|
||||
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
final enabled =
|
||||
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
|
||||
return TToggleMenu(
|
||||
value: privacyModeState.isNotEmpty,
|
||||
onChanged: enabled
|
||||
@@ -810,18 +819,29 @@ List<TToggleMenu> toolbarPrivacyMode(
|
||||
})
|
||||
];
|
||||
} else {
|
||||
return privacyModeImpls.map((e) {
|
||||
final visibleImpls = hasPrivacyModePermission
|
||||
? privacyModeImpls
|
||||
: privacyModeImpls.where((e) {
|
||||
final implKey = (e as List<dynamic>)[0] as String;
|
||||
return privacyModeState.value == implKey;
|
||||
}).toList();
|
||||
return visibleImpls.map((e) {
|
||||
final implKey = (e as List<dynamic>)[0] as String;
|
||||
final implName = (e)[1] as String;
|
||||
final enabled = !ffiModel.viewOnly &&
|
||||
(hasPrivacyModePermission || privacyModeState.value == implKey);
|
||||
return TToggleMenu(
|
||||
child: Text(translate(implName)),
|
||||
value: privacyModeState.value == implKey,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
});
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
if (value && !hasPrivacyModePermission) return;
|
||||
togglePrivacyModeTime = DateTime.now();
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId, implKey: implKey, on: value);
|
||||
}
|
||||
: null);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,9 @@ const String kOptionTerminalPersistent = "terminal-persistent";
|
||||
const String kOptionEnableTunnel = "enable-tunnel";
|
||||
const String kOptionEnableRemoteRestart = "enable-remote-restart";
|
||||
const String kOptionEnableBlockInput = "enable-block-input";
|
||||
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
|
||||
const String kOptionEnablePermChangeInAcceptWindow =
|
||||
"enable-perm-change-in-accept-window";
|
||||
const String kOptionAllowRemoteConfigModification =
|
||||
"allow-remote-config-modification";
|
||||
const String kOptionVerificationMethod = "verification-method";
|
||||
|
||||
@@ -1062,6 +1062,10 @@ class _SafetyState extends State<_Safety> with AutomaticKeepAliveClientMixin {
|
||||
_OptionCheckBox(context, 'Enable blocking user input',
|
||||
kOptionEnableBlockInput,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
_OptionCheckBox(
|
||||
context, 'Enable privacy mode', kOptionEnablePrivacyMode,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
_OptionCheckBox(context, 'Enable remote configuration modification',
|
||||
kOptionAllowRemoteConfigModification,
|
||||
enabled: enabled, fakeValue: fakeValue),
|
||||
|
||||
@@ -610,19 +610,24 @@ class _PrivilegeBoard extends StatefulWidget {
|
||||
class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
late final client = widget.client;
|
||||
Widget buildPermissionIcon(bool enabled, IconData iconData,
|
||||
Function(bool)? onTap, String tooltipText) {
|
||||
Function(bool)? onTap, String tooltipText,
|
||||
{required bool canModify}) {
|
||||
return Tooltip(
|
||||
message: "$tooltipText: ${enabled ? "ON" : "OFF"}",
|
||||
waitDuration: Duration.zero,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? MyTheme.accent : Colors.grey[700],
|
||||
color: enabled
|
||||
? (canModify ? MyTheme.accent : MyTheme.accent.withOpacity(0.6))
|
||||
: Colors.grey[700],
|
||||
borderRadius: BorderRadius.circular(10.0),
|
||||
),
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled)),
|
||||
onTap: canModify
|
||||
? () =>
|
||||
checkClickTime(widget.client.id, () => onTap?.call(!enabled))
|
||||
: null,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
@@ -643,6 +648,9 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
Widget build(BuildContext context) {
|
||||
final crossAxisCount = 4;
|
||||
final spacing = 10.0;
|
||||
final canModifyPermission =
|
||||
bind.mainGetBuildinOption(key: kOptionEnablePermChangeInAcceptWindow) !=
|
||||
'N';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 160.0,
|
||||
@@ -689,6 +697,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -703,6 +712,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
]
|
||||
: [
|
||||
@@ -719,6 +729,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable keyboard/mouse'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.clipboard,
|
||||
@@ -733,6 +744,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable clipboard'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.audio,
|
||||
@@ -747,6 +759,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable audio'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.file,
|
||||
@@ -761,6 +774,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable file copy and paste'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.restart,
|
||||
@@ -775,6 +789,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable remote restart'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
buildPermissionIcon(
|
||||
client.recording,
|
||||
@@ -789,6 +804,7 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable recording session'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
// only windows support block input
|
||||
if (isWindows)
|
||||
@@ -805,6 +821,23 @@ class _PrivilegeBoardState extends State<_PrivilegeBoard> {
|
||||
});
|
||||
},
|
||||
translate('Enable blocking user input'),
|
||||
canModify: canModifyPermission,
|
||||
),
|
||||
if (bind.mainSupportedPrivacyModeImpls() != '[]')
|
||||
buildPermissionIcon(
|
||||
client.privacyMode,
|
||||
Icons.visibility_off,
|
||||
(enabled) {
|
||||
bind.cmSwitchPermission(
|
||||
connId: client.id,
|
||||
name: "privacy_mode",
|
||||
enabled: enabled);
|
||||
setState(() {
|
||||
client.privacyMode = enabled;
|
||||
});
|
||||
},
|
||||
translate('Enable privacy mode'),
|
||||
canModify: canModifyPermission,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ class TerminalPage extends StatefulWidget {
|
||||
final bool? isSharedPassword;
|
||||
final String? connToken;
|
||||
final int terminalId;
|
||||
|
||||
/// Tab key for focus management, passed from parent to avoid duplicate construction
|
||||
final String tabKey;
|
||||
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
|
||||
@@ -43,6 +44,9 @@ class TerminalPage extends StatefulWidget {
|
||||
|
||||
class _TerminalPageState extends State<TerminalPage>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
static const EdgeInsets _defaultTerminalPadding =
|
||||
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
|
||||
late FFI _ffi;
|
||||
late TerminalModel _terminalModel;
|
||||
double? _cellHeight;
|
||||
@@ -155,13 +159,27 @@ class _TerminalPageState extends State<TerminalPage>
|
||||
// extra space left after dividing the available height by the height of a single
|
||||
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
final cellHeight = _cellHeight;
|
||||
if (!heightPx.isFinite ||
|
||||
heightPx <= 0 ||
|
||||
cellHeight == null ||
|
||||
!cellHeight.isFinite ||
|
||||
cellHeight <= 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final rows = (heightPx / cellHeight).floor();
|
||||
if (rows <= 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final extraSpace = heightPx - rows * cellHeight;
|
||||
if (!extraSpace.isFinite || extraSpace < 0) {
|
||||
return _defaultTerminalPadding;
|
||||
}
|
||||
final rows = (heightPx / _cellHeight!).floor();
|
||||
final extraSpace = heightPx - rows * _cellHeight!;
|
||||
final topBottom = extraSpace / 2.0;
|
||||
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: _defaultTerminalPadding.horizontal / 2,
|
||||
vertical: topBottom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -46,6 +46,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
.setTitle(getWindowNameWithId(id));
|
||||
};
|
||||
tabController.onRemoved = (_, id) => onRemoveId(id);
|
||||
tabController.onCloseWindow = _closeWindowFromConnection;
|
||||
final terminalId = params['terminalId'] ?? _nextTerminalId++;
|
||||
tabController.add(_createTerminalTab(
|
||||
peerId: params['id'],
|
||||
@@ -144,6 +145,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
_windowClosing = true;
|
||||
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
|
||||
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
|
||||
// Keep the cleanup target lookup below synchronous before its first await:
|
||||
// it relies on the current frame still retaining each TerminalPage's FFI/model.
|
||||
tabController.clear();
|
||||
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
|
||||
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
|
||||
@@ -368,8 +371,34 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
final persistentSessions =
|
||||
args['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
|
||||
var peerId = args['peer_id'] as String? ?? '';
|
||||
if (peerId.isEmpty) {
|
||||
if (tabController.state.value.tabs.isEmpty ||
|
||||
tabController.state.value.selected >=
|
||||
tabController.state.value.tabs.length) {
|
||||
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
|
||||
return;
|
||||
}
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
final parsed = _parseTabKey(currentTab.key);
|
||||
if (parsed == null) return;
|
||||
peerId = parsed.$1;
|
||||
}
|
||||
final existingTerminalIds = tabController.state.value.tabs
|
||||
.map((tab) => _parseTabKey(tab.key))
|
||||
.where((parsed) => parsed != null && parsed.$1 == peerId)
|
||||
.map((parsed) => parsed!.$2)
|
||||
.toSet();
|
||||
if (existingTerminalIds.isEmpty) {
|
||||
debugPrint(
|
||||
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
|
||||
return;
|
||||
}
|
||||
for (final terminalId in sortedSessions) {
|
||||
_addNewTerminalForCurrentPeer(terminalId: terminalId);
|
||||
if (!existingTerminalIds.add(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
_addNewTerminal(peerId, terminalId: terminalId);
|
||||
// A delay is required to ensure the UI has sufficient time to update
|
||||
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
|
||||
// may be called prematurely while the tab widget is still in the tab controller.
|
||||
@@ -546,6 +575,11 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeWindowFromConnection() async {
|
||||
await _closeAllTabs();
|
||||
await WindowController.fromWindowId(windowId()).close();
|
||||
}
|
||||
|
||||
int windowId() {
|
||||
return widget.params["windowId"];
|
||||
}
|
||||
|
||||
@@ -996,10 +996,10 @@ class _DisplayMenuState extends State<_DisplayMenu> {
|
||||
toggles(),
|
||||
];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (ffi.connType == ConnType.defaultConn &&
|
||||
ffiModel.keyboard &&
|
||||
pi.features.privacyMode) {
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
(pi.features.privacyMode || privacyModeState.isNotEmpty) &&
|
||||
(ffiModel.keyboard || privacyModeState.isNotEmpty)) {
|
||||
final privacyModeList =
|
||||
toolbarPrivacyMode(privacyModeState, context, id, ffi);
|
||||
if (privacyModeList.length == 1) {
|
||||
|
||||
@@ -99,6 +99,7 @@ class DesktopTabController {
|
||||
/// index, key
|
||||
Function(int, String)? onRemoved;
|
||||
Function(String)? onSelected;
|
||||
Future<void> Function()? onCloseWindow;
|
||||
|
||||
DesktopTabController(
|
||||
{required this.tabType, this.onRemoved, this.onSelected});
|
||||
|
||||
@@ -1183,7 +1183,8 @@ void showOptions(
|
||||
List<TToggleMenu> privacyModeList = [];
|
||||
// privacy mode
|
||||
final privacyModeState = PrivacyModeState.find(id);
|
||||
if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
|
||||
if ((gFFI.ffiModel.pi.features.privacyMode && gFFI.ffiModel.keyboard) ||
|
||||
privacyModeState.isNotEmpty) {
|
||||
privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
|
||||
if (privacyModeList.length == 1) {
|
||||
displayToggles.add(privacyModeList[0]);
|
||||
|
||||
@@ -583,9 +583,16 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
Widget build(BuildContext context) {
|
||||
final serverModel = Provider.of<ServerModel>(context);
|
||||
final hasAudioPermission = androidVersion >= 30;
|
||||
final hideStopService =
|
||||
isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
final hideStopService = isAndroid &&
|
||||
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
|
||||
final allowPermChangeInAcceptWindow = option2bool(
|
||||
kOptionEnablePermChangeInAcceptWindow,
|
||||
bind.mainGetBuildinOption(
|
||||
key: kOptionEnablePermChangeInAcceptWindow,
|
||||
));
|
||||
final permissionChangeLocked = isAndroid &&
|
||||
serverModel.clients.any((c) => !c.disconnected) &&
|
||||
!allowPermChangeInAcceptWindow;
|
||||
return PaddingCard(
|
||||
title: translate("Permissions"),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
@@ -608,13 +615,21 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
|
||||
? () => showScamWarning(context, serverModel)
|
||||
: serverModel.toggleService),
|
||||
PermissionRow(translate("Input Control"), serverModel.inputOk,
|
||||
serverModel.toggleInput),
|
||||
PermissionRow(translate("Transfer file"), serverModel.fileOk,
|
||||
serverModel.toggleFile),
|
||||
PermissionRow(
|
||||
translate("Input Control"),
|
||||
serverModel.inputOk,
|
||||
serverModel.toggleInput,
|
||||
),
|
||||
PermissionRow(
|
||||
translate("Transfer file"),
|
||||
serverModel.fileOk,
|
||||
serverModel.toggleFile,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
hasAudioPermission
|
||||
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
|
||||
serverModel.toggleAudio)
|
||||
serverModel.toggleAudio,
|
||||
enabled: !permissionChangeLocked)
|
||||
: Row(children: [
|
||||
Icon(Icons.info_outline).marginOnly(right: 15),
|
||||
Expanded(
|
||||
@@ -623,19 +638,25 @@ class _PermissionCheckerState extends State<PermissionChecker> {
|
||||
style: const TextStyle(color: MyTheme.darkGray),
|
||||
))
|
||||
]),
|
||||
PermissionRow(translate("Enable clipboard"), serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard),
|
||||
PermissionRow(
|
||||
translate("Enable clipboard"),
|
||||
serverModel.clipboardOk,
|
||||
serverModel.toggleClipboard,
|
||||
enabled: !permissionChangeLocked,
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
class PermissionRow extends StatelessWidget {
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed, {Key? key})
|
||||
const PermissionRow(this.name, this.isOk, this.onPressed,
|
||||
{Key? key, this.enabled = true})
|
||||
: super(key: key);
|
||||
|
||||
final String name;
|
||||
final bool isOk;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -644,9 +665,11 @@ class PermissionRow extends StatelessWidget {
|
||||
contentPadding: EdgeInsets.all(0),
|
||||
title: Text(name),
|
||||
value: isOk,
|
||||
onChanged: (bool value) {
|
||||
onPressed();
|
||||
});
|
||||
onChanged: enabled
|
||||
? (bool value) {
|
||||
onPressed();
|
||||
}
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleAudio() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
@@ -316,7 +316,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleFile() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
@@ -345,7 +345,7 @@ class ServerModel with ChangeNotifier {
|
||||
}
|
||||
|
||||
toggleInput() async {
|
||||
if (clients.isNotEmpty) {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (_inputOk) {
|
||||
@@ -549,10 +549,19 @@ class ServerModel with ChangeNotifier {
|
||||
if (index < 0) {
|
||||
_clients.add(client);
|
||||
} else {
|
||||
if (_clients[index].authorized) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients[index].authorized = true;
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
}
|
||||
} else {
|
||||
if (_clients.any((c) => c.id == client.id)) {
|
||||
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||
if (index >= 0) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients.add(client);
|
||||
@@ -818,6 +827,7 @@ class Client {
|
||||
bool restart = false;
|
||||
bool recording = false;
|
||||
bool blockInput = false;
|
||||
bool privacyMode = false;
|
||||
bool disconnected = false;
|
||||
bool fromSwitch = false;
|
||||
bool inVoiceCall = false;
|
||||
@@ -846,6 +856,7 @@ class Client {
|
||||
restart = json['restart'];
|
||||
recording = json['recording'];
|
||||
blockInput = json['block_input'];
|
||||
privacyMode = json['privacy_mode'] ?? privacyMode;
|
||||
disconnected = json['disconnected'];
|
||||
fromSwitch = json['from_switch'];
|
||||
inVoiceCall = json['in_voice_call'];
|
||||
@@ -870,6 +881,7 @@ class Client {
|
||||
data['restart'] = restart;
|
||||
data['recording'] = recording;
|
||||
data['block_input'] = blockInput;
|
||||
data['privacy_mode'] = privacyMode;
|
||||
data['disconnected'] = disconnected;
|
||||
data['from_switch'] = fromSwitch;
|
||||
data['in_voice_call'] = inVoiceCall;
|
||||
|
||||
@@ -27,25 +27,30 @@ class TerminalModel with ChangeNotifier {
|
||||
// Buffer for output data received before terminal view has valid dimensions.
|
||||
// This prevents NaN errors when writing to terminal before layout is complete.
|
||||
final _pendingOutputChunks = <String>[];
|
||||
final _pendingOutputSuppressFlags = <bool>[];
|
||||
int _pendingOutputSize = 0;
|
||||
static const int _kMaxOutputBufferChars = 8 * 1024;
|
||||
// View ready state: true when terminal has valid dimensions, safe to write
|
||||
bool _terminalViewReady = false;
|
||||
|
||||
bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows;
|
||||
bool _markViewReadyScheduled = false;
|
||||
bool _suppressTerminalOutput = false;
|
||||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// If we press the `Enter` button on Android,
|
||||
// `data` can be '\r' or '\n' when using different keyboards.
|
||||
// Android -> Windows. '\r' works, but '\n' does not. '\n' is just a newline.
|
||||
// Android -> Linux. Both '\r' and '\n' work as expected (execute a command).
|
||||
// So when we receive '\n', we may need to convert it to '\r' to ensure compatibility.
|
||||
// Desktop -> Desktop works fine.
|
||||
// Check if we are on mobile or web(mobile), and convert '\n' to '\r'.
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
|
||||
// - Peer Windows: '\r' works, '\n' is just a newline.
|
||||
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
|
||||
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
|
||||
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
|
||||
// (https://github.com/rustdesk/rustdesk/issues/14907).
|
||||
// So on mobile / web-mobile, always normalize a lone '\n' to '\r'.
|
||||
// We deliberately do not touch multi-character payloads (e.g. pasted text)
|
||||
// so embedded newlines in pasted content are preserved.
|
||||
final isMobileOrWebMobile = (isMobile || (isWeb && !isWebDesktop));
|
||||
if (isMobileOrWebMobile && isPeerWindows && data == '\n') {
|
||||
if (isMobileOrWebMobile && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
@@ -70,7 +75,10 @@ class TerminalModel with ChangeNotifier {
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = _handleInput;
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// Validate all dimensions before using them
|
||||
@@ -84,7 +92,7 @@ class TerminalModel with ChangeNotifier {
|
||||
// Mark terminal view as ready and flush any buffered output on first valid resize.
|
||||
// Must be after onResizeExternal so the view layer has valid dimensions before flushing.
|
||||
if (!_terminalViewReady) {
|
||||
_markViewReady();
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
@@ -110,14 +118,16 @@ class TerminalModel with ChangeNotifier {
|
||||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// Fire and forget - don't block onReady
|
||||
openTerminal().catchError((e) {
|
||||
// Fire and forget - don't block onReady. If the transport reconnects while
|
||||
// this model is still open, re-send OpenTerminal so the remote service marks
|
||||
// the persistent session active again and resumes output streaming.
|
||||
openTerminal(force: _terminalOpened).catchError((e) {
|
||||
debugPrint('[TerminalModel] Error opening terminal: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> openTerminal() async {
|
||||
if (_terminalOpened) return;
|
||||
Future<void> openTerminal({bool force = false}) async {
|
||||
if (_terminalOpened && !force) return;
|
||||
// Request the remote side to open a terminal with default shell
|
||||
// The remote side will decide which shell to use based on its OS
|
||||
|
||||
@@ -275,9 +285,12 @@ class TerminalModel with ChangeNotifier {
|
||||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// On reconnect ("Reconnected to existing terminal"), server may replay recent output.
|
||||
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear.
|
||||
// We intentionally accept this tradeoff for now to keep logic simple.
|
||||
// On reconnect, the server may replay recent output. That replay can include
|
||||
// terminal queries like DSR/DA; xterm answers them through onOutput as
|
||||
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
|
||||
final replayTerminalOutput = evt['replay_terminal_output'];
|
||||
_suppressNextTerminalDataOutput = replayTerminalOutput == true ||
|
||||
message == 'Reconnected to existing terminal with pending output';
|
||||
|
||||
// Fallback: if terminal view is not yet ready but already has valid
|
||||
// dimensions (e.g. layout completed before open response arrived),
|
||||
@@ -285,7 +298,7 @@ class TerminalModel with ChangeNotifier {
|
||||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_markViewReady();
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
@@ -297,12 +310,16 @@ class TerminalModel with ChangeNotifier {
|
||||
});
|
||||
|
||||
final persistentSessions =
|
||||
evt['persistent_sessions'] as List<dynamic>? ?? [];
|
||||
(evt['persistent_sessions'] as List<dynamic>? ?? [])
|
||||
.whereType<int>()
|
||||
.where((id) => !parent.terminalModels.containsKey(id))
|
||||
.toList();
|
||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
kWindowEventRestoreTerminalSessions,
|
||||
jsonEncode({
|
||||
'peer_id': id,
|
||||
'persistent_sessions': persistentSessions,
|
||||
}));
|
||||
}
|
||||
@@ -332,6 +349,8 @@ class TerminalModel with ChangeNotifier {
|
||||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
@@ -351,7 +370,7 @@ class TerminalModel with ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
_writeToTerminal(text);
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||
}
|
||||
@@ -361,7 +380,10 @@ class TerminalModel with ChangeNotifier {
|
||||
/// Write text to terminal, buffering if the view is not yet ready.
|
||||
/// All terminal output should go through this method to avoid NaN errors
|
||||
/// from writing before the terminal view has valid layout dimensions.
|
||||
void _writeToTerminal(String text) {
|
||||
void _writeToTerminal(
|
||||
String text, {
|
||||
bool suppressTerminalOutput = false,
|
||||
}) {
|
||||
if (!_terminalViewReady) {
|
||||
// If a single chunk exceeds the cap, keep only its tail.
|
||||
// Note: truncation may split a multi-byte ANSI escape sequence,
|
||||
@@ -373,34 +395,73 @@ class TerminalModel with ChangeNotifier {
|
||||
_pendingOutputChunks
|
||||
..clear()
|
||||
..add(truncated);
|
||||
_pendingOutputSuppressFlags
|
||||
..clear()
|
||||
..add(suppressTerminalOutput);
|
||||
_pendingOutputSize = truncated.length;
|
||||
} else {
|
||||
_pendingOutputChunks.add(text);
|
||||
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
|
||||
_pendingOutputSize += text.length;
|
||||
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
|
||||
while (_pendingOutputSize > _kMaxOutputBufferChars &&
|
||||
_pendingOutputChunks.length > 1) {
|
||||
final removed = _pendingOutputChunks.removeAt(0);
|
||||
_pendingOutputSuppressFlags.removeAt(0);
|
||||
_pendingOutputSize -= removed.length;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
terminal.write(text);
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
for (final chunk in _pendingOutputChunks) {
|
||||
terminal.write(chunk);
|
||||
for (var i = 0; i < _pendingOutputChunks.length; i++) {
|
||||
_writeTerminalChunk(
|
||||
_pendingOutputChunks[i],
|
||||
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
|
||||
);
|
||||
}
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
}
|
||||
|
||||
void _writeTerminalChunk(
|
||||
String text, {
|
||||
required bool suppressTerminalOutput,
|
||||
}) {
|
||||
if (!suppressTerminalOutput) {
|
||||
terminal.write(text);
|
||||
return;
|
||||
}
|
||||
final previous = _suppressTerminalOutput;
|
||||
_suppressTerminalOutput = true;
|
||||
try {
|
||||
terminal.write(text);
|
||||
} finally {
|
||||
_suppressTerminalOutput = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark terminal view as ready and flush buffered output.
|
||||
void _scheduleMarkViewReady() {
|
||||
if (_disposed || _terminalViewReady || _markViewReadyScheduled) return;
|
||||
_markViewReadyScheduled = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_markViewReadyScheduled = false;
|
||||
if (_disposed || _terminalViewReady) return;
|
||||
if (terminal.viewWidth > 0 && terminal.viewHeight > 0) {
|
||||
_markViewReady();
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
void _markViewReady() {
|
||||
if (_terminalViewReady) return;
|
||||
_terminalViewReady = true;
|
||||
@@ -426,7 +487,10 @@ class TerminalModel with ChangeNotifier {
|
||||
// Clear buffers to free memory
|
||||
_inputBuffer.clear();
|
||||
_pendingOutputChunks.clear();
|
||||
_pendingOutputSuppressFlags.clear();
|
||||
_pendingOutputSize = 0;
|
||||
_markViewReadyScheduled = false;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
// Terminal cleanup is handled server-side when service closes
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -1729,7 +1729,7 @@ class RustdeskImpl {
|
||||
}
|
||||
|
||||
String mainSupportedPrivacyModeImpls({dynamic hint}) {
|
||||
throw UnimplementedError("mainSupportedPrivacyModeImpls");
|
||||
return '[]';
|
||||
}
|
||||
|
||||
String mainSupportedInputSource({dynamic hint}) {
|
||||
|
||||
Submodule libs/hbb_common updated: 87b11a7959...42af0f0aed
@@ -1745,6 +1745,9 @@ pub struct LoginConfigHandler {
|
||||
pub direct: Option<bool>,
|
||||
pub received: bool,
|
||||
switch_uuid: Option<String>,
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
switch_back_allowed: bool,
|
||||
pub save_ab_password_to_recent: bool, // true: connected with ab password
|
||||
pub other_server: Option<(String, String, String)>,
|
||||
pub custom_fps: Arc<Mutex<Option<usize>>>,
|
||||
@@ -1861,6 +1864,11 @@ impl LoginConfigHandler {
|
||||
|
||||
self.direct = None;
|
||||
self.received = false;
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
self.switch_back_allowed = false;
|
||||
}
|
||||
self.switch_uuid = switch_uuid;
|
||||
self.adapter_luid = adapter_luid;
|
||||
self.selected_windows_session_id = None;
|
||||
@@ -1874,6 +1882,23 @@ impl LoginConfigHandler {
|
||||
self.is_terminal_admin = is_terminal_admin;
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn allow_switch_back_once(&mut self) {
|
||||
self.switch_back_allowed = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn consume_switch_back_permission(&mut self) -> bool {
|
||||
if self.switch_back_allowed {
|
||||
self.switch_back_allowed = false;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -3377,6 +3402,36 @@ pub fn handle_login_error(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
async fn consume_local_switch_sides_uuid(id: &str, uuid: &Uuid) -> bool {
|
||||
let Ok(mut conn) = crate::ipc::connect(1000, "").await else {
|
||||
return false;
|
||||
};
|
||||
let uuid = uuid.to_string();
|
||||
if conn
|
||||
.send(&crate::ipc::Data::SwitchSidesUuid(
|
||||
uuid.clone(),
|
||||
id.to_owned(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match conn.next_timeout(1000).await {
|
||||
Ok(Some(crate::ipc::Data::SwitchSidesUuid(
|
||||
returned_uuid,
|
||||
returned_id,
|
||||
Some(true),
|
||||
))) => {
|
||||
returned_uuid == uuid && returned_id == id
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle hash message sent by peer.
|
||||
/// Hash will be used for login.
|
||||
///
|
||||
@@ -3397,12 +3452,22 @@ pub async fn handle_hash(
|
||||
// Take care of password application order
|
||||
|
||||
// switch_uuid
|
||||
let uuid = lc.write().unwrap().switch_uuid.take();
|
||||
if let Some(uuid) = uuid {
|
||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||
lc.write().unwrap().password_source = Default::default();
|
||||
return;
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
{
|
||||
let uuid = lc.write().unwrap().switch_uuid.take();
|
||||
if let Some(uuid) = uuid {
|
||||
if let Ok(uuid) = uuid::Uuid::from_str(&uuid) {
|
||||
let id = lc.read().unwrap().id.clone();
|
||||
if !consume_local_switch_sides_uuid(&id, &uuid).await {
|
||||
log::warn!("Ignored untrusted switch_uuid");
|
||||
} else {
|
||||
lc.write().unwrap().allow_switch_back_once();
|
||||
send_switch_login_request(lc.clone(), peer, uuid).await;
|
||||
lc.write().unwrap().password_source = Default::default();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// last password
|
||||
|
||||
@@ -1797,6 +1797,9 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
Ok(Permission::BlockInput) => {
|
||||
self.handler.set_permission("block_input", p.enabled);
|
||||
}
|
||||
Ok(Permission::PrivacyMode) => {
|
||||
self.handler.set_permission("privacy_mode", p.enabled);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1920,9 +1923,23 @@ impl<T: InvokeUiSession> Remote<T> {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Some(misc::Union::SwitchBack(_)) => {
|
||||
#[cfg(feature = "flutter")]
|
||||
self.handler.switch_back(&self.handler.get_id());
|
||||
let allow_switch_back = self
|
||||
.handler
|
||||
.lc
|
||||
.write()
|
||||
.unwrap()
|
||||
.consume_switch_back_permission();
|
||||
if allow_switch_back {
|
||||
self.handler.switch_back(&self.handler.get_id());
|
||||
} else {
|
||||
log::warn!(
|
||||
"Ignored unsolicited SwitchBack from {}",
|
||||
self.handler.get_id()
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
|
||||
@@ -1135,6 +1135,10 @@ impl InvokeUiSession for FlutterHandler {
|
||||
("message", json!(&opened.message)),
|
||||
("pid", json!(opened.pid)),
|
||||
("service_id", json!(&opened.service_id)),
|
||||
(
|
||||
"replay_terminal_output",
|
||||
json!(opened.replay_terminal_output),
|
||||
),
|
||||
];
|
||||
if !opened.persistent_sessions.is_empty() {
|
||||
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
|
||||
|
||||
@@ -972,6 +972,27 @@ pub fn main_show_option(_key: String) -> SyncReturn<bool> {
|
||||
}
|
||||
|
||||
pub fn main_set_option(key: String, value: String) {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let is_permission_option = key.eq(config::keys::OPTION_ENABLE_CLIPBOARD)
|
||||
|| key.eq(config::keys::OPTION_ENABLE_FILE_TRANSFER)
|
||||
|| key.eq(config::keys::OPTION_ENABLE_AUDIO);
|
||||
let allow_perm_change_in_accept_window = config::option2bool(
|
||||
config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
&crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW),
|
||||
);
|
||||
if is_permission_option
|
||||
&& !allow_perm_change_in_accept_window
|
||||
&& crate::ui_cm_interface::has_active_clients()
|
||||
{
|
||||
log::info!(
|
||||
"blocked main_set_option by policy, key={}, value={}",
|
||||
key,
|
||||
value
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
if key.eq(config::keys::OPTION_ENABLE_KEYBOARD) {
|
||||
crate::ui_cm_interface::switch_permission_all(
|
||||
@@ -1019,7 +1040,29 @@ pub fn main_get_options_sync() -> SyncReturn<String> {
|
||||
}
|
||||
|
||||
pub fn main_set_options(json: String) {
|
||||
let map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
|
||||
let mut map: HashMap<String, String> = serde_json::from_str(&json).unwrap_or(HashMap::new());
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
let allow_perm_change_in_accept_window = config::option2bool(
|
||||
config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
&crate::get_builtin_option(config::keys::OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW),
|
||||
);
|
||||
if !allow_perm_change_in_accept_window && crate::ui_cm_interface::has_active_clients() {
|
||||
for key in [
|
||||
config::keys::OPTION_ENABLE_CLIPBOARD,
|
||||
config::keys::OPTION_ENABLE_FILE_TRANSFER,
|
||||
config::keys::OPTION_ENABLE_AUDIO,
|
||||
] {
|
||||
if let Some(value) = map.remove(key) {
|
||||
log::info!(
|
||||
"blocked main_set_options item by policy, key={}, value={}",
|
||||
key,
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !map.is_empty() {
|
||||
set_options(map)
|
||||
}
|
||||
@@ -2170,7 +2213,7 @@ pub fn cm_elevate_portable(conn_id: i32) {
|
||||
}
|
||||
|
||||
pub fn cm_switch_back(conn_id: i32) {
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
crate::ui_cm_interface::switch_back(conn_id);
|
||||
}
|
||||
|
||||
|
||||
23
src/ipc.rs
23
src/ipc.rs
@@ -237,6 +237,7 @@ pub enum Data {
|
||||
restart: bool,
|
||||
recording: bool,
|
||||
block_input: bool,
|
||||
privacy_mode: bool,
|
||||
from_switch: bool,
|
||||
},
|
||||
ChatMessage {
|
||||
@@ -284,7 +285,14 @@ pub enum Data {
|
||||
Empty,
|
||||
Disconnected,
|
||||
DataPortableService(DataPortableService),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesRequest(String),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesUuid(String, String, Option<bool>),
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
SwitchSidesBack,
|
||||
UrlLink(String),
|
||||
VoiceCallIncoming,
|
||||
@@ -770,6 +778,8 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
Data::TestRendezvousServer => {
|
||||
crate::test_rendezvous_server();
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesRequest(id) => {
|
||||
let uuid = uuid::Uuid::new_v4();
|
||||
crate::server::insert_switch_sides_uuid(id, uuid.clone());
|
||||
@@ -779,6 +789,19 @@ async fn handle(data: Data, stream: &mut Connection) {
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::SwitchSidesUuid(uuid, id, None) => {
|
||||
let allowed = uuid
|
||||
.parse::<uuid::Uuid>()
|
||||
.map(|uuid| crate::server::remove_pending_switch_sides_uuid(&id, &uuid))
|
||||
.unwrap_or(false);
|
||||
allow_err!(
|
||||
stream
|
||||
.send(&Data::SwitchSidesUuid(uuid, id, Some(allowed)))
|
||||
.await
|
||||
);
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Data::Plugin(plugin) => crate::plugin::ipc::handle_plugin(plugin, stream).await,
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "اسم العرض"),
|
||||
("password-hidden-tip", "كلمة المرور مخفية"),
|
||||
("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Імя для адлюстравання"),
|
||||
("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."),
|
||||
("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "显示名称"),
|
||||
("password-hidden-tip", "永久密码已设置(已隐藏)"),
|
||||
("preset-password-in-use-tip", "当前使用预设密码"),
|
||||
("Enable privacy mode", "允许隐私模式"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Anzeigename"),
|
||||
("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."),
|
||||
("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."),
|
||||
("Enable privacy mode", "Datenschutzmodus aktivieren"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Εμφανιζόμενο όνομα"),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Nom d’affichage"),
|
||||
("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."),
|
||||
("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."),
|
||||
("Enable privacy mode", "Activer le mode de confidentialité"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -742,5 +742,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "ડિસ્પ્લે નામ"),
|
||||
("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."),
|
||||
("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Kijelző név"),
|
||||
("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."),
|
||||
("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Visualizza nome"),
|
||||
("password-hidden-tip", "È impostata una password permanente (nascosta)."),
|
||||
("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."),
|
||||
("Enable privacy mode", "Abilita modalità privacy"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "表示名"),
|
||||
("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"),
|
||||
("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "표시 이름"),
|
||||
("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."),
|
||||
("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Naam Weergeven"),
|
||||
("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."),
|
||||
("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Nazwa wyświetlana"),
|
||||
("password-hidden-tip", "Ustawiono (ukryto) stare hasło."),
|
||||
("preset-password-in-use-tip", "Obecnie używane jest hasło domyślne."),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Nume afișat"),
|
||||
("password-hidden-tip", "Parola este ascunsă din motive de securitate. Fă clic pe pictograma ochiului pentru a o afișa."),
|
||||
("preset-password-in-use-tip", "Se folosește o parolă prestabilită. Se recomandă setarea unei parole personalizate pentru securitate sporită."),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "Отображаемое имя"),
|
||||
("password-hidden-tip", "Установлен постоянный пароль (скрытый)."),
|
||||
("preset-password-in-use-tip", "Установленный пароль сейчас используется."),
|
||||
("Enable privacy mode", "Использовать режим конфиденциальности"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -741,7 +741,8 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("keep-awake-during-incoming-sessions-label", "Gelen oturumlar süresince ekranı açık tutun"),
|
||||
("Continue with {}", "{} ile devam et"),
|
||||
("Display Name", "Görünen Ad"),
|
||||
("password-hidden-tip", "Şifre gizli"),
|
||||
("preset-password-in-use-tip", "Önceden ayarlanmış şifre kullanılıyor"),
|
||||
("password-hidden-tip", "Parola gizli"),
|
||||
("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"),
|
||||
("Enable privacy mode", "Gizlilik modunu etkinleştir"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "顯示名稱"),
|
||||
("password-hidden-tip", "固定密碼已設定(已隱藏)"),
|
||||
("preset-password-in-use-tip", "目前正在使用預設密碼"),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -743,5 +743,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", ""),
|
||||
("password-hidden-tip", ""),
|
||||
("preset-password-in-use-tip", ""),
|
||||
("Enable privacy mode", ""),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -73,11 +73,17 @@ lazy_static::lazy_static! {
|
||||
static ref ALIVE_CONNS: Arc::<Mutex<Vec<i32>>> = Default::default();
|
||||
pub static ref AUTHED_CONNS: Arc::<Mutex<Vec<AuthedConn>>> = Default::default();
|
||||
pub static ref CONTROL_PERMISSIONS_ARRAY: Arc::<Mutex<Vec<(i32, ControlPermissions)>>> = Default::default();
|
||||
static ref SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||
static ref WAKELOCK_SENDER: Arc::<Mutex<std::sync::mpsc::Sender<(usize, usize)>>> = Arc::new(Mutex::new(start_wakelock_thread()));
|
||||
static ref WAKELOCK_KEEP_AWAKE_OPTION: Arc::<Mutex<Option<bool>>> = Default::default();
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
lazy_static::lazy_static! {
|
||||
static ref SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||
static ref PENDING_SWITCH_SIDES_UUID: Arc::<Mutex<HashMap<String, (Instant, uuid::Uuid)>>> = Default::default();
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
@@ -241,6 +247,7 @@ pub struct Connection {
|
||||
restart: bool,
|
||||
recording: bool,
|
||||
block_input: bool,
|
||||
privacy_mode: bool,
|
||||
control_permissions: Option<ControlPermissions>,
|
||||
last_test_delay: Option<Instant>,
|
||||
network_delay: u32,
|
||||
@@ -431,6 +438,7 @@ impl Connection {
|
||||
restart: Self::permission(keys::OPTION_ENABLE_REMOTE_RESTART, &control_permissions),
|
||||
recording: Self::permission(keys::OPTION_ENABLE_RECORD_SESSION, &control_permissions),
|
||||
block_input: Self::permission(keys::OPTION_ENABLE_BLOCK_INPUT, &control_permissions),
|
||||
privacy_mode: Self::permission(keys::OPTION_ENABLE_PRIVACY_MODE, &control_permissions),
|
||||
control_permissions,
|
||||
last_test_delay: None,
|
||||
network_delay: 0,
|
||||
@@ -527,6 +535,9 @@ impl Connection {
|
||||
if !conn.block_input {
|
||||
conn.send_permission(Permission::BlockInput, false).await;
|
||||
}
|
||||
if !conn.privacy_mode {
|
||||
conn.send_permission(Permission::PrivacyMode, false).await;
|
||||
}
|
||||
let mut test_delay_timer =
|
||||
crate::rustdesk_interval(time::interval_at(Instant::now(), TEST_DELAY_TIMEOUT));
|
||||
let mut last_recv_time = Instant::now();
|
||||
@@ -674,6 +685,46 @@ impl Connection {
|
||||
} else if &name == "block_input" {
|
||||
conn.block_input = enabled;
|
||||
conn.send_permission(Permission::BlockInput, enabled).await;
|
||||
} else if &name == "privacy_mode" {
|
||||
// Keep permission state and runtime state consistent:
|
||||
// when revoking the permission, try to leave privacy mode first.
|
||||
// Otherwise we could end up in an inconsistent state where
|
||||
// permission looks disabled while privacy mode is still active.
|
||||
if !enabled && privacy_mode::is_in_privacy_mode() {
|
||||
if let Some(conn_id) = privacy_mode::get_privacy_mode_conn_id() {
|
||||
if conn_id == conn.inner.id() {
|
||||
let impl_key =
|
||||
privacy_mode::get_cur_impl_key().unwrap_or_default();
|
||||
let turn_off_res =
|
||||
privacy_mode::turn_off_privacy(conn_id, None);
|
||||
match turn_off_res {
|
||||
Some(Ok(_)) => {
|
||||
let msg_out = crate::common::make_privacy_mode_msg(
|
||||
back_notification::PrivacyModeState::PrvOffByPeer,
|
||||
impl_key.clone(),
|
||||
);
|
||||
conn.send(msg_out).await;
|
||||
}
|
||||
_ => {
|
||||
let msg_out = Self::turn_off_privacy_result_to_msg(
|
||||
turn_off_res,
|
||||
impl_key,
|
||||
);
|
||||
conn.send(msg_out).await;
|
||||
// Turn-off failed, so revert CM's optimistic toggle
|
||||
// and keep the previous permission value.
|
||||
conn.send_to_cm(ipc::Data::SwitchPermission {
|
||||
name: "privacy_mode".to_owned(),
|
||||
enabled: conn.privacy_mode,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.privacy_mode = enabled;
|
||||
conn.send_permission(Permission::PrivacyMode, enabled).await;
|
||||
}
|
||||
}
|
||||
ipc::Data::RawMessage(bytes) => {
|
||||
@@ -730,6 +781,8 @@ impl Connection {
|
||||
log::error!("Failed to start portable service from cm: {:?}", e);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
ipc::Data::SwitchSidesBack => {
|
||||
let mut misc = Misc::new();
|
||||
misc.set_switch_back(SwitchBack::default());
|
||||
@@ -978,7 +1031,7 @@ impl Connection {
|
||||
|
||||
if let Some(video_privacy_conn_id) = privacy_mode::get_privacy_mode_conn_id() {
|
||||
if video_privacy_conn_id == id {
|
||||
let _ = Self::turn_off_privacy_to_msg(id);
|
||||
let _ = Self::turn_off_privacy_to_msg(id, String::new());
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "flutter", feature = "plugin_framework"))]
|
||||
@@ -1900,6 +1953,7 @@ impl Connection {
|
||||
restart: self.restart,
|
||||
recording: self.recording,
|
||||
block_input: self.block_input,
|
||||
privacy_mode: self.privacy_mode,
|
||||
from_switch: self.from_switch,
|
||||
});
|
||||
}
|
||||
@@ -2175,6 +2229,7 @@ impl Connection {
|
||||
keys::OPTION_ENABLE_REMOTE_RESTART => Some(Permission::restart),
|
||||
keys::OPTION_ENABLE_RECORD_SESSION => Some(Permission::recording),
|
||||
keys::OPTION_ENABLE_BLOCK_INPUT => Some(Permission::block_input),
|
||||
keys::OPTION_ENABLE_PRIVACY_MODE => Some(Permission::privacy_mode),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(permission) = permission {
|
||||
@@ -2532,6 +2587,7 @@ impl Connection {
|
||||
}
|
||||
} else if let Some(message::Union::SwitchSidesResponse(_s)) = msg.union {
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
if let Some(lr) = _s.lr.clone().take() {
|
||||
self.handle_login_request_without_validation(&lr).await;
|
||||
SWITCH_SIDES_UUID
|
||||
@@ -3247,8 +3303,13 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
Some(misc::Union::SwitchSidesRequest(s)) => {
|
||||
if let Ok(uuid) = uuid::Uuid::from_slice(&s.uuid.to_vec()[..]) {
|
||||
crate::server::insert_pending_switch_sides_uuid(
|
||||
self.lr.my_id.clone(),
|
||||
uuid.clone(),
|
||||
);
|
||||
crate::run_me(vec![
|
||||
"--connect",
|
||||
&self.lr.my_id,
|
||||
@@ -4145,6 +4206,15 @@ impl Connection {
|
||||
}
|
||||
|
||||
async fn turn_on_privacy(&mut self, impl_key: String) {
|
||||
if !self.is_authed_remote_conn() || !self.privacy_mode {
|
||||
let msg_out = crate::common::make_privacy_mode_msg(
|
||||
back_notification::PrivacyModeState::PrvOnFailedDenied,
|
||||
impl_key,
|
||||
);
|
||||
self.send(msg_out).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let msg_out = if !privacy_mode::is_privacy_mode_supported() {
|
||||
crate::common::make_privacy_mode_msg_with_details(
|
||||
back_notification::PrivacyModeState::PrvNotSupported,
|
||||
@@ -4186,7 +4256,7 @@ impl Connection {
|
||||
"Check privacy mode failed: {}, turn off privacy mode.",
|
||||
&err_msg
|
||||
);
|
||||
let _ = Self::turn_off_privacy_to_msg(self.inner.id);
|
||||
let _ = Self::turn_off_privacy_to_msg(self.inner.id, String::new());
|
||||
crate::common::make_privacy_mode_msg_with_details(
|
||||
back_notification::PrivacyModeState::PrvOnFailed,
|
||||
err_msg,
|
||||
@@ -4205,6 +4275,7 @@ impl Connection {
|
||||
if privacy_mode::is_in_privacy_mode() {
|
||||
let _ = Self::turn_off_privacy_to_msg(
|
||||
privacy_mode::INVALID_PRIVACY_MODE_CONN_ID,
|
||||
String::new(),
|
||||
);
|
||||
}
|
||||
crate::common::make_privacy_mode_msg_with_details(
|
||||
@@ -4232,14 +4303,23 @@ impl Connection {
|
||||
impl_key,
|
||||
)
|
||||
} else {
|
||||
Self::turn_off_privacy_to_msg(self.inner.id)
|
||||
Self::turn_off_privacy_to_msg(self.inner.id, impl_key)
|
||||
};
|
||||
self.send(msg_out).await;
|
||||
}
|
||||
|
||||
pub fn turn_off_privacy_to_msg(_conn_id: i32) -> Message {
|
||||
let impl_key = "".to_owned();
|
||||
match privacy_mode::turn_off_privacy(_conn_id, None) {
|
||||
pub fn turn_off_privacy_to_msg(_conn_id: i32, impl_key: String) -> Message {
|
||||
Self::turn_off_privacy_result_to_msg(
|
||||
privacy_mode::turn_off_privacy(_conn_id, None),
|
||||
impl_key,
|
||||
)
|
||||
}
|
||||
|
||||
fn turn_off_privacy_result_to_msg(
|
||||
turn_off_res: Option<hbb_common::ResultType<()>>,
|
||||
impl_key: String,
|
||||
) -> Message {
|
||||
match turn_off_res {
|
||||
Some(Ok(_)) => crate::common::make_privacy_mode_msg(
|
||||
back_notification::PrivacyModeState::PrvOffSucceeded,
|
||||
impl_key,
|
||||
@@ -4872,6 +4952,8 @@ impl Connection {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||
SWITCH_SIDES_UUID
|
||||
.lock()
|
||||
@@ -4879,6 +4961,27 @@ pub fn insert_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||
.insert(id, (tokio::time::Instant::now(), uuid));
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn insert_pending_switch_sides_uuid(id: String, uuid: uuid::Uuid) {
|
||||
let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap();
|
||||
uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10));
|
||||
uuids.insert(id, (tokio::time::Instant::now(), uuid));
|
||||
}
|
||||
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn remove_pending_switch_sides_uuid(id: &str, uuid: &uuid::Uuid) -> bool {
|
||||
let mut uuids = PENDING_SWITCH_SIDES_UUID.lock().unwrap();
|
||||
uuids.retain(|_, (instant, _)| instant.elapsed() < Duration::from_secs(10));
|
||||
if uuids.get(id).map(|(_, stored_uuid)| stored_uuid == uuid) == Some(true) {
|
||||
uuids.remove(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
async fn start_ipc(
|
||||
mut rx_to_cm: mpsc::UnboundedReceiver<ipc::Data>,
|
||||
|
||||
@@ -318,6 +318,35 @@ pub fn get_default_shell() -> String {
|
||||
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
|
||||
}
|
||||
|
||||
fn utf8_shell_args(shell: &str) -> Vec<String> {
|
||||
let name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(shell)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if name == "cmd.exe" || name == "cmd" {
|
||||
return vec!["/K".to_string(), "chcp 65001 >NUL".to_string()];
|
||||
}
|
||||
|
||||
if name == "pwsh.exe" || name == "pwsh" || name == "powershell.exe" {
|
||||
return vec![
|
||||
"-NoLogo".to_string(),
|
||||
"-NoExit".to_string(),
|
||||
"-Command".to_string(),
|
||||
"chcp.com 65001 > $null; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8".to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn configure_utf8_shell_command(shell: &str, cmd: &mut CommandBuilder) {
|
||||
for arg in utf8_shell_args(shell) {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the SID of the user from a token.
|
||||
/// Returns a Vec<u8> containing the SID bytes.
|
||||
pub fn get_user_sid_from_token(user_token: UserToken) -> Result<Vec<u8>> {
|
||||
@@ -831,7 +860,8 @@ pub fn run_terminal_helper(args: &[String]) -> Result<()> {
|
||||
let shell = get_default_shell();
|
||||
log::debug!("Using shell: {}", shell);
|
||||
|
||||
let cmd = CommandBuilder::new(&shell);
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
configure_utf8_shell_command(&shell, &mut cmd);
|
||||
let mut child = pty_pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
|
||||
@@ -20,10 +20,11 @@ use std::{
|
||||
// Windows-specific imports from terminal_helper module
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::terminal_helper::{
|
||||
create_named_pipe_server, encode_helper_message, encode_resize_message,
|
||||
is_helper_process_running, launch_terminal_helper_with_token, wait_for_pipe_connection,
|
||||
HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle, WinTerminateProcess,
|
||||
WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS, WIN_WAIT_OBJECT_0,
|
||||
configure_utf8_shell_command, create_named_pipe_server, encode_helper_message,
|
||||
encode_resize_message, is_helper_process_running, launch_terminal_helper_with_token,
|
||||
wait_for_pipe_connection, HelperProcessGuard, OwnedHandle, SendableHandle, WinCloseHandle,
|
||||
WinTerminateProcess, WinWaitForSingleObject, MSG_TYPE_DATA, PIPE_CONNECTION_TIMEOUT_MS,
|
||||
WIN_WAIT_OBJECT_0,
|
||||
};
|
||||
|
||||
const MAX_OUTPUT_BUFFER_SIZE: usize = 1024 * 1024; // 1MB per terminal
|
||||
@@ -133,6 +134,26 @@ fn get_default_shell() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn locale_value_is_utf8(value: &str) -> bool {
|
||||
let value = value.to_ascii_uppercase();
|
||||
value.contains("UTF-8") || value.contains("UTF8")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn should_force_process_utf8_ctype() -> bool {
|
||||
if let Ok(value) = std::env::var("LC_ALL") {
|
||||
return !locale_value_is_utf8(&value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("LC_CTYPE") {
|
||||
return !locale_value_is_utf8(&value);
|
||||
}
|
||||
if let Ok(value) = std::env::var("LANG") {
|
||||
return !locale_value_is_utf8(&value);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn is_service_specified_user(service_id: &str) -> Option<bool> {
|
||||
get_service(service_id).map(|s| s.lock().unwrap().is_specified_user)
|
||||
}
|
||||
@@ -435,6 +456,7 @@ impl OutputBuffer {
|
||||
// Find first newline in new data
|
||||
if let Some(newline_pos) = data.iter().position(|&b| b == b'\n') {
|
||||
last_line.extend_from_slice(&data[..=newline_pos]);
|
||||
self.total_size += newline_pos + 1;
|
||||
start = newline_pos + 1;
|
||||
self.last_line_incomplete = false;
|
||||
} else {
|
||||
@@ -473,7 +495,28 @@ impl OutputBuffer {
|
||||
// Trim old data if buffer is too large
|
||||
while self.total_size > MAX_OUTPUT_BUFFER_SIZE || self.lines.len() > MAX_BUFFER_LINES {
|
||||
if let Some(removed) = self.lines.pop_front() {
|
||||
self.total_size -= removed.len();
|
||||
if removed.len() > self.total_size {
|
||||
log::error!(
|
||||
"OutputBuffer total_size underflow avoided: total_size={}, removed_len={}, lines_len={}",
|
||||
self.total_size,
|
||||
removed.len(),
|
||||
self.lines.len()
|
||||
);
|
||||
self.total_size = self.lines.iter().map(|line| line.len()).sum();
|
||||
} else {
|
||||
self.total_size -= removed.len();
|
||||
}
|
||||
if self.lines.is_empty() {
|
||||
self.last_line_incomplete = false;
|
||||
}
|
||||
} else {
|
||||
log::error!(
|
||||
"OutputBuffer trim invariant broken: total_size={}, lines_len=0",
|
||||
self.total_size
|
||||
);
|
||||
self.total_size = 0;
|
||||
self.last_line_incomplete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,6 +574,97 @@ impl OutputBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the largest prefix of `buf` that does not end in the middle of a UTF-8
|
||||
/// code point. Invalid bytes are treated as complete so they can continue
|
||||
/// downstream and be rendered with replacement characters if needed.
|
||||
fn find_utf8_split_point(buf: &[u8]) -> usize {
|
||||
if buf.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let start = buf.len().saturating_sub(3);
|
||||
for i in (start..buf.len()).rev() {
|
||||
let b = buf[i];
|
||||
if b & 0x80 == 0 {
|
||||
return buf.len();
|
||||
}
|
||||
if b & 0xC0 == 0x80 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let seq_len = if b & 0xE0 == 0xC0 {
|
||||
2
|
||||
} else if b & 0xF0 == 0xE0 {
|
||||
3
|
||||
} else if b & 0xF8 == 0xF0 {
|
||||
4
|
||||
} else {
|
||||
return buf.len();
|
||||
};
|
||||
|
||||
return if buf.len() - i >= seq_len {
|
||||
buf.len()
|
||||
} else {
|
||||
i
|
||||
};
|
||||
}
|
||||
|
||||
buf.len()
|
||||
}
|
||||
|
||||
// Terminal output currently follows a UTF-8 text model end to end: the service
|
||||
// keeps replay buffers on UTF-8 boundaries, and Flutter decodes payload bytes as
|
||||
// UTF-8 before writing to xterm. This accumulator only prevents splitting a
|
||||
// trailing UTF-8 code point across PTY reads. Supporting non-UTF-8 terminals
|
||||
// would need a separate design covering remote encoding detection, Flutter
|
||||
// decoding, replay truncation, and input transcoding.
|
||||
#[derive(Default)]
|
||||
struct Utf8ChunkAccumulator {
|
||||
remainder: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Utf8ChunkAccumulator {
|
||||
fn push_chunk(&mut self, mut data: Vec<u8>) -> Option<Vec<u8>> {
|
||||
if data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let had_remainder = !self.remainder.is_empty();
|
||||
if had_remainder {
|
||||
let mut combined = std::mem::take(&mut self.remainder);
|
||||
combined.extend_from_slice(&data);
|
||||
data = combined;
|
||||
}
|
||||
|
||||
let split = find_utf8_split_point(&data);
|
||||
if split == data.len() {
|
||||
return Some(data);
|
||||
}
|
||||
|
||||
// Only hold back a candidate incomplete suffix when we have evidence that
|
||||
// the bytes before it are already UTF-8 text. If split is 0, the whole
|
||||
// read may be the start of a UTF-8 character, so keep it for the next read.
|
||||
if !had_remainder && split > 0 && std::str::from_utf8(&data[..split]).is_err() {
|
||||
return Some(data);
|
||||
}
|
||||
|
||||
self.remainder = data.split_off(split);
|
||||
if data.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(data)
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Option<Vec<u8>> {
|
||||
if self.remainder.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(std::mem::take(&mut self.remainder))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to send data through the output channel with rate-limited drop logging.
|
||||
/// Returns `true` if the caller should break out of the read loop (channel disconnected).
|
||||
fn try_send_output(
|
||||
@@ -570,7 +704,11 @@ fn try_send_output(
|
||||
false
|
||||
}
|
||||
Err(mpsc::TrySendError::Disconnected(_)) => {
|
||||
log::debug!("Terminal {}{} output channel disconnected", terminal_id, label);
|
||||
log::debug!(
|
||||
"Terminal {}{} output channel disconnected",
|
||||
terminal_id,
|
||||
label
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -937,15 +1075,35 @@ impl TerminalServiceProxy {
|
||||
if let Some(session_arc) = service.sessions.get(&open.terminal_id) {
|
||||
// Reconnect to existing terminal
|
||||
let mut session = session_arc.lock().unwrap();
|
||||
// Directly enter Active state with pending buffer for immediate streaming.
|
||||
// Historical buffer is sent first by read_outputs(), then real-time data follows.
|
||||
// No overlap: pending_buffer comes from output_buffer (pre-disconnect history),
|
||||
// while received_data in read_outputs() comes from the channel (post-reconnect).
|
||||
// During disconnect, the run loop (sp.ok()) exits so read_outputs() stops being
|
||||
// called; output_buffer is not updated, and channel data may be lost if it fills up.
|
||||
let buffer = session
|
||||
// Directly enter Active state with pending replay for immediate streaming.
|
||||
// The replay combines output_buffer history and the channel backlog that was
|
||||
// already pending at reconnect time so the client can suppress stale xterm
|
||||
// query answers without requiring a protobuf schema change.
|
||||
// During disconnect, read_outputs() is not called; channel data can still be lost
|
||||
// if output_rx fills before reconnect drains it.
|
||||
let mut buffer = session
|
||||
.output_buffer
|
||||
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
||||
let mut reconnect_backlog = Vec::new();
|
||||
if let Some(output_rx) = &session.output_rx {
|
||||
// Cap reconnect-time drain so a chatty PTY cannot keep OpenTerminal
|
||||
// inside this loop indefinitely. Remaining output is drained by read_outputs().
|
||||
for _ in 0..CHANNEL_BUFFER_SIZE {
|
||||
let Ok(data) = output_rx.try_recv() else {
|
||||
break;
|
||||
};
|
||||
reconnect_backlog.push(data);
|
||||
}
|
||||
}
|
||||
let has_reconnect_backlog = !reconnect_backlog.is_empty();
|
||||
for data in reconnect_backlog {
|
||||
session.output_buffer.append(&data);
|
||||
}
|
||||
if has_reconnect_backlog {
|
||||
buffer = session
|
||||
.output_buffer
|
||||
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
|
||||
}
|
||||
let has_pending = !buffer.is_empty();
|
||||
session.state = SessionState::Active {
|
||||
pending_buffer: if has_pending { Some(buffer) } else { None },
|
||||
@@ -959,9 +1117,14 @@ impl TerminalServiceProxy {
|
||||
let mut opened = TerminalOpened::new();
|
||||
opened.terminal_id = open.terminal_id;
|
||||
opened.success = true;
|
||||
opened.message = "Reconnected to existing terminal".to_string();
|
||||
opened.message = if has_pending {
|
||||
"Reconnected to existing terminal with pending output".to_string()
|
||||
} else {
|
||||
"Reconnected to existing terminal".to_string()
|
||||
};
|
||||
opened.pid = session.pid;
|
||||
opened.service_id = self.service_id.clone();
|
||||
opened.replay_terminal_output = has_pending;
|
||||
if service.needs_session_sync {
|
||||
if service.sessions.len() > 1 {
|
||||
// No need to include the current terminal in the list.
|
||||
@@ -1016,6 +1179,9 @@ impl TerminalServiceProxy {
|
||||
#[allow(unused_mut)]
|
||||
let mut cmd = CommandBuilder::new(&shell);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
configure_utf8_shell_command(&shell, &mut cmd);
|
||||
|
||||
// macOS-specific terminal configuration
|
||||
// 1. Use login shell (-l) to load user's shell profile (~/.zprofile, ~/.bash_profile)
|
||||
// This ensures PATH includes Homebrew paths (/opt/homebrew/bin, /usr/local/bin)
|
||||
@@ -1036,6 +1202,12 @@ impl TerminalServiceProxy {
|
||||
};
|
||||
cmd.env("TERM", term);
|
||||
log::debug!("Set TERM={} for macOS PTY", term);
|
||||
|
||||
if should_force_process_utf8_ctype() {
|
||||
cmd.env_remove("LC_ALL");
|
||||
cmd.env("LC_CTYPE", "en_US.UTF-8");
|
||||
log::debug!("Set LC_CTYPE=en_US.UTF-8 for macOS PTY");
|
||||
}
|
||||
}
|
||||
|
||||
// Note: On Windows with user_token, we use helper mode (handle_open_with_helper)
|
||||
@@ -1086,6 +1258,7 @@ impl TerminalServiceProxy {
|
||||
let reader_thread = thread::spawn(move || {
|
||||
let mut reader = reader;
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
||||
let mut drop_count: u64 = 0;
|
||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||
@@ -1095,13 +1268,25 @@ impl TerminalServiceProxy {
|
||||
// EOF
|
||||
// This branch can be reached when the child process exits on macOS.
|
||||
// But not on Linux and Windows in my tests.
|
||||
if let Some(data) = utf8_chunks.finish() {
|
||||
let _ = try_send_output(
|
||||
&output_tx,
|
||||
data,
|
||||
terminal_id,
|
||||
"",
|
||||
&mut drop_count,
|
||||
&mut last_drop_warn,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Ok(n) => {
|
||||
if exiting.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let data = buf[..n].to_vec();
|
||||
let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else {
|
||||
continue;
|
||||
};
|
||||
// Use try_send to avoid blocking the reader thread when channel is full.
|
||||
// During disconnect, the run loop (sp.ok()) stops and read_outputs() is
|
||||
// no longer called, so the channel won't be drained. Blocking send would
|
||||
@@ -1308,12 +1493,23 @@ impl TerminalServiceProxy {
|
||||
let terminal_id = open.terminal_id;
|
||||
let reader_thread = thread::spawn(move || {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut utf8_chunks = Utf8ChunkAccumulator::default();
|
||||
let mut drop_count: u64 = 0;
|
||||
// Initialize to > 5s ago so the first drop triggers a warning immediately.
|
||||
let mut last_drop_warn = Instant::now() - Duration::from_secs(6);
|
||||
loop {
|
||||
match output_pipe.read(&mut buf) {
|
||||
Ok(0) => {
|
||||
if let Some(data) = utf8_chunks.finish() {
|
||||
let _ = try_send_output(
|
||||
&output_tx,
|
||||
data,
|
||||
terminal_id,
|
||||
" (helper)",
|
||||
&mut drop_count,
|
||||
&mut last_drop_warn,
|
||||
);
|
||||
}
|
||||
// EOF - helper process exited
|
||||
log::debug!("Terminal {} helper output EOF", terminal_id);
|
||||
break;
|
||||
@@ -1322,7 +1518,9 @@ impl TerminalServiceProxy {
|
||||
if exiting.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
let data = buf[..n].to_vec();
|
||||
let Some(data) = utf8_chunks.push_chunk(buf[..n].to_vec()) else {
|
||||
continue;
|
||||
};
|
||||
// Use try_send to avoid blocking the reader thread (same as direct PTY mode)
|
||||
if try_send_output(
|
||||
&output_tx,
|
||||
@@ -1462,20 +1660,28 @@ impl TerminalServiceProxy {
|
||||
data: &TerminalData,
|
||||
) -> Result<Option<TerminalResponse>> {
|
||||
if let Some(session_arc) = session {
|
||||
let mut session = session_arc.lock().unwrap();
|
||||
session.update_activity();
|
||||
if let Some(input_tx) = &session.input_tx {
|
||||
// Encode data for helper mode or send raw for direct PTY mode
|
||||
#[cfg(target_os = "windows")]
|
||||
let msg = if session.is_helper_mode {
|
||||
encode_helper_message(MSG_TYPE_DATA, &data.data)
|
||||
} else {
|
||||
data.data.to_vec()
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let msg = data.data.to_vec();
|
||||
let input = {
|
||||
let mut session = session_arc.lock().unwrap();
|
||||
session.update_activity();
|
||||
if let Some(input_tx) = session.input_tx.clone() {
|
||||
// Encode data for helper mode or send raw for direct PTY mode
|
||||
#[cfg(target_os = "windows")]
|
||||
let msg = if session.is_helper_mode {
|
||||
encode_helper_message(MSG_TYPE_DATA, &data.data)
|
||||
} else {
|
||||
data.data.to_vec()
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let msg = data.data.to_vec();
|
||||
|
||||
// Send data to writer thread
|
||||
Some((input_tx, msg))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((input_tx, msg)) = input {
|
||||
// Send outside the session lock; SyncSender::send can block when full.
|
||||
if let Err(e) = input_tx.send(msg) {
|
||||
log::error!(
|
||||
"Failed to send data to terminal {}: {}",
|
||||
@@ -1683,10 +1889,6 @@ impl TerminalServiceProxy {
|
||||
}
|
||||
}
|
||||
|
||||
if has_activity {
|
||||
session.update_activity();
|
||||
}
|
||||
|
||||
// Update buffer (always buffer for reconnection support)
|
||||
for data in &received_data {
|
||||
session.output_buffer.append(data);
|
||||
@@ -1696,7 +1898,7 @@ impl TerminalServiceProxy {
|
||||
// Data is already buffered above and will be sent on next reconnection.
|
||||
// Use a scoped block to limit the mutable borrow of session.state,
|
||||
// so we can immutably borrow other session fields afterwards.
|
||||
let sigwinch_action = {
|
||||
let (replay_buffer, sigwinch_action) = {
|
||||
let (pending_buffer, sigwinch) = match &mut session.state {
|
||||
SessionState::Active {
|
||||
pending_buffer,
|
||||
@@ -1705,19 +1907,12 @@ impl TerminalServiceProxy {
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Send pending buffer response first (set on reconnection in handle_open).
|
||||
// This ensures historical buffer is sent before any real-time data.
|
||||
if let Some(buffer) = pending_buffer.take() {
|
||||
if !buffer.is_empty() {
|
||||
responses
|
||||
.push(Self::create_terminal_data_response(terminal_id, buffer));
|
||||
}
|
||||
}
|
||||
let replay_buffer = pending_buffer.take();
|
||||
|
||||
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
|
||||
// Each phase is a single PTY resize, spaced ~30ms apart by the polling
|
||||
// interval, ensuring the TUI app sees a real size change on each signal.
|
||||
match sigwinch {
|
||||
let sigwinch_action = match sigwinch {
|
||||
SigwinchPhase::TempResize { retries } => {
|
||||
if *retries == 0 {
|
||||
log::warn!(
|
||||
@@ -1745,9 +1940,20 @@ impl TerminalServiceProxy {
|
||||
}
|
||||
}
|
||||
SigwinchPhase::Idle => None,
|
||||
}
|
||||
};
|
||||
(replay_buffer, sigwinch_action)
|
||||
};
|
||||
|
||||
if let Some(buffer) = replay_buffer {
|
||||
if !buffer.is_empty() {
|
||||
responses.push(Self::create_terminal_data_response(terminal_id, buffer));
|
||||
}
|
||||
}
|
||||
|
||||
if has_activity {
|
||||
session.update_activity();
|
||||
}
|
||||
|
||||
// Execute SIGWINCH resize outside the mutable borrow scope of session.state.
|
||||
if let Some(action) = sigwinch_action {
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -1845,3 +2051,116 @@ impl TerminalServiceProxy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_utf8_split_point, OutputBuffer, Utf8ChunkAccumulator, MAX_BUFFER_LINES};
|
||||
|
||||
#[test]
|
||||
fn utf8_split_point_returns_full_len_for_complete_input() {
|
||||
assert_eq!(find_utf8_split_point(b"hello"), 5);
|
||||
assert_eq!(find_utf8_split_point("中文".as_bytes()), "中文".len());
|
||||
assert_eq!(find_utf8_split_point("😀".as_bytes()), "😀".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_split_point_detects_incomplete_trailing_sequence() {
|
||||
let data = [b'a', 0xE4, 0xB8];
|
||||
assert_eq!(find_utf8_split_point(&data), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_split_point_keeps_malformed_prefix_but_buffers_trailing_lead_byte() {
|
||||
let data = [0xFF, 0xE4];
|
||||
assert_eq!(find_utf8_split_point(&data), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_split_point_treats_orphan_continuations_as_complete() {
|
||||
let data = [0x80, 0x81, 0x82];
|
||||
assert_eq!(find_utf8_split_point(&data), data.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_chunk_accumulator_reassembles_split_multibyte_output() {
|
||||
let full = "你好世界".as_bytes();
|
||||
let mut chunker = Utf8ChunkAccumulator::default();
|
||||
let mut output = Vec::new();
|
||||
|
||||
for chunk in full.chunks(5) {
|
||||
if let Some(data) = chunker.push_chunk(chunk.to_vec()) {
|
||||
output.extend_from_slice(&data);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(data) = chunker.finish() {
|
||||
output.extend_from_slice(&data);
|
||||
}
|
||||
|
||||
assert_eq!(output, full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_chunk_accumulator_buffers_leading_split_multibyte_output() {
|
||||
let mut chunker = Utf8ChunkAccumulator::default();
|
||||
|
||||
assert!(chunker.push_chunk(vec![0xE4]).is_none());
|
||||
assert!(chunker.push_chunk(vec![0xB8]).is_none());
|
||||
assert_eq!(
|
||||
chunker.push_chunk(vec![0xAD]),
|
||||
Some("中".as_bytes().to_vec())
|
||||
);
|
||||
assert!(chunker.finish().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_chunk_accumulator_flushes_incomplete_tail_on_finish() {
|
||||
let mut chunker = Utf8ChunkAccumulator::default();
|
||||
assert_eq!(chunker.push_chunk(vec![b'a', 0xE4]), Some(vec![b'a']));
|
||||
assert_eq!(chunker.finish(), Some(vec![0xE4]));
|
||||
assert!(chunker.finish().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_chunk_accumulator_does_not_stall_on_malformed_bytes() {
|
||||
let mut chunker = Utf8ChunkAccumulator::default();
|
||||
assert_eq!(chunker.push_chunk(vec![0xFF]), Some(vec![0xFF]));
|
||||
assert!(chunker.finish().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_chunk_accumulator_buffers_lone_utf8_lead_bytes() {
|
||||
let mut chunker = Utf8ChunkAccumulator::default();
|
||||
assert!(chunker.push_chunk(vec![0xE4]).is_none());
|
||||
assert_eq!(chunker.finish(), Some(vec![0xE4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_chunk_accumulator_does_not_hold_back_non_utf8_prefixes() {
|
||||
let mut chunker = Utf8ChunkAccumulator::default();
|
||||
assert_eq!(chunker.push_chunk(vec![0xFF, 0xE4]), Some(vec![0xFF, 0xE4]));
|
||||
assert!(chunker.finish().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_buffer_trim_after_incomplete_merge_does_not_underflow() {
|
||||
let mut buffer = OutputBuffer::new();
|
||||
|
||||
// Create an incomplete line first.
|
||||
buffer.append(b"hello");
|
||||
|
||||
// Merge a large chunk that contains the first newline at the tail.
|
||||
// This exercises the "append to last incomplete line" branch.
|
||||
let mut large = vec![b'a'; 30_000];
|
||||
large.push(b'\n');
|
||||
buffer.append(&large);
|
||||
|
||||
// Exceed MAX_BUFFER_LINES so trim pops the first large merged line.
|
||||
for _ in 0..=MAX_BUFFER_LINES {
|
||||
buffer.append(b"x\n");
|
||||
}
|
||||
|
||||
let actual_size: usize = buffer.lines.iter().map(|line| line.len()).sum();
|
||||
assert_eq!(buffer.total_size, actual_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +372,11 @@ impl UI {
|
||||
is_installed()
|
||||
}
|
||||
|
||||
fn get_supported_privacy_mode_impls(&self) -> String {
|
||||
serde_json::to_string(&crate::privacy_mode::get_supported_privacy_mode_impl())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_root(&self) -> bool {
|
||||
is_root()
|
||||
}
|
||||
@@ -752,6 +757,7 @@ impl sciter::EventHandler for UI {
|
||||
fn get_icon();
|
||||
fn install_me(String, String);
|
||||
fn is_installed();
|
||||
fn get_supported_privacy_mode_impls();
|
||||
fn is_root();
|
||||
fn is_release();
|
||||
fn set_socks(String, String, String);
|
||||
|
||||
@@ -93,6 +93,13 @@ div.permissions > div:active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
div.permissions.locked,
|
||||
div.permissions.locked *,
|
||||
div.permissions.locked > div:active {
|
||||
cursor: default !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
icon.keyboard {
|
||||
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAgVBMVEUAAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////9d3yJTAAAAKnRSTlMA0Gd/0y8ILZgbJffDPUwV2nvzt+TMqZxyU7CMb1pYQyzsvKunkXE4AwJnNC24AAAA+0lEQVQ4y83O2U7DMBCF4ZMxk9rZk26kpQs7nPd/QJy4EiLbLf01N5Y/2YP/qxDFQvGB5NPC/ZpVnfJx4b5xyGfF95rkHvNCWH1u+N6J6T0sC7gqRy8uGPfBLEbozPXUjlkQKwGaFPNizwQbwkx0TDvhCii34ExZCSQVBdzIOEOyeclSHgBGXkpeygXSQgStACtWx4Z8rr8COHOvfEP/IbbsQAToFUAAV1M408IIjIGYAPoCSNRP7DQutfQTqxuAiH7UUg1FaJR2AGrrx52sK2ye28LZ0wBAEyR6y8X+NADhm1B4fgiiHXbRrTrxpwEY9RdM9wsepnvFHfUDwYEeiwAJr/gAAAAASUVORK5CYII=');
|
||||
}
|
||||
@@ -121,6 +128,10 @@ icon.block_input {
|
||||
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAjdJREFUWEe1V8tNAzEQfXOHAx2QG0UgQSqBFIIgHdABoQqOhBq4cCMlcMh90FvZq/HEXtvJxlKUZNceP783no+gY6jqNYBHAHcA+JufXTDBb37eRWTbalZqE82mz7W55v0ABMBGRCLA7PJJAKr6AiC3sT11NHyf2SEyQjvtAMKp3wBYo9VTGbYegjxxU65d5tg4YEBVbwF8ALgw2lLX4in80QqyZUEkAMLCb7P5n4hcdWifTA32Pg0bByA8AE4+oL3n9A1s7ERkEeeNAJzD/QC4OVaCAgjrU7wdK86zAHREJSKqyvvORRxVb67JFOT4NfYGpxwAqCo34oYcKxHZhOdzg7D2BhYigHj6RJ+5QbjrPezlqR61sZTOKYfztSUBWPoXpdA5FwjnC2sCGK+eiNRC8yw+oap0RiayLQHEPwf65zx7DibMoXcEEB0wq/85QJQAbEVkWbvP8f0pTFi/65ZgjtuRyJ7QYWL0OZnwTmiLDobH5nLqGDlUlcmON49jQwnsg/Wxma/VJ1zcGQIR7+OYJGyqbJWhhwlDPxh3JpNRL4Ba7nAsJckoYaFUv7UCyslBvQ3TNDWEfVsPJGH2FCkKTPAxD8ox+poFwJfZqqX15H6eYyK+TgJeriidLCJ7wAQHZ4Udy7u9iFxaG7mynEx4EF1leZDANzV7AE8i8joJICz2cvBxbExIYTZYTTQmxTxTzP+VnvC8rZlLOLEj7m5OW6JqtTs2US6247Hvy7XnX0OV05FP/gHde5fLZaGS8AAAAABJRU5ErkJggg==');
|
||||
}
|
||||
|
||||
icon.privacy_mode {
|
||||
background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAB7UlEQVR4AdyTrVYDMRCFuyjqiiuuOJA46sCVR6jDgQTXN+CgQIJCgkOCA0cduOLAgaOOuuW7czYhyWY5FcXQc28n85O5m9nsUuuPf/9IoCzLLnxd9MTCET3SvNckQnwL7lfcpnYueIGiKNbY8QYjERo+wZK4HuAcK94rVvGSWCO8gCqKjAixTXLPsAl7ldBxriASqAo6lfUnqUTaWAP5FajTYjxGCNXeYSRAwSflToBlKxSZKSCiMoUa6Uh+QNW/B37LC9D8lkTYHNegTf7JqNP8b5RB5AT7AkPoNqqXxUyATT28AUzhRuFFaLpDUYc9V1ihr7+EA/JdxUyAxQTWQDM3CuVSEWugGiUztJ5OIJPPhlKRbFEVXJZ1Anph8iNyTCsieA0dvIgCQY3ckBtyTIBjfuDcwRR2TPJDElkRcrpd6XcyJm7X2ATY3CKwi1UxxkNPeyiP/BAa8LVZObtdBMOPcYbvX7wXYJNE2lidBuNxyhgm0I1LCdcgFXmguXqoxhgJKELBKvYMhljH+ULEwDr8mEIRXWHSP6gJKIXIESxYh3PHzWJK1IuwjpAVcBWIhHPX0x2QE/vkHGofIzUevwr4KhZ003wvsOKYkAcxXfPoxbvk3AJuQ5MNRNwFsNKFCaibRGB0CxcqIJGU3wAAAP//8GtoDAAAAAZJREFUAwCJJuAxFVNbWwAAAABJRU5ErkJggg==');
|
||||
}
|
||||
|
||||
div.outer_buttons {
|
||||
flow:vertical;
|
||||
border-spacing:8;
|
||||
|
||||
14
src/ui/cm.rs
14
src/ui/cm.rs
@@ -36,7 +36,8 @@ impl InvokeUiCM for SciterHandler {
|
||||
client.file,
|
||||
client.restart,
|
||||
client.recording,
|
||||
client.block_input
|
||||
client.block_input,
|
||||
client.privacy_mode
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -157,9 +158,18 @@ impl SciterConnectionManager {
|
||||
crate::ui_interface::get_option(key)
|
||||
}
|
||||
|
||||
fn get_builtin_option(&self, key: String) -> String {
|
||||
crate::ui_interface::get_builtin_option(&key)
|
||||
}
|
||||
|
||||
fn hide_cm(&self) -> bool {
|
||||
*crate::ui::cm::HIDE_CM.lock().unwrap()
|
||||
}
|
||||
|
||||
fn get_supported_privacy_mode_impls(&self) -> String {
|
||||
serde_json::to_string(&crate::privacy_mode::get_supported_privacy_mode_impl())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl sciter::EventHandler for SciterConnectionManager {
|
||||
@@ -181,6 +191,8 @@ impl sciter::EventHandler for SciterConnectionManager {
|
||||
fn can_elevate();
|
||||
fn elevate_portable(i32);
|
||||
fn get_option(String);
|
||||
fn get_builtin_option(String);
|
||||
fn hide_cm();
|
||||
fn get_supported_privacy_mode_impls();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ var body;
|
||||
var connections = [];
|
||||
var show_chat = false;
|
||||
var show_elevation = true;
|
||||
var is_privacy_mode_supported = handler.get_supported_privacy_mode_impls() != '[]';
|
||||
var allow_perm_change_in_accept_window =
|
||||
handler.get_builtin_option('enable-perm-change-in-accept-window') != 'N';
|
||||
var svg_elevate = <svg t="1667992597853" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1850" width="16" height="16" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M892.761 160.724v426.504c0 25.588-6.419 51.036-19.177 76.339-12.798 25.336-29.547 49.86-50.254 73.627-20.707 23.79-44.372 46.296-70.97 67.516-26.589 21.244-53.543 40.177-80.921 56.768-27.363 16.623-53.968 30.461-79.801 41.438-25.809 11.008-48.433 18.547-67.871 22.64l-9.203 1.53-8.43-1.53c-19.958-4.093-43.094-11.632-69.432-22.64-26.337-10.969-53.708-24.816-82.080-41.438-28.388-16.591-56.256-35.524-83.618-56.768-27.378-21.219-51.776-43.725-73.265-67.516-21.488-23.759-38.868-48.291-52.155-73.627-13.319-25.305-19.974-50.759-19.974-76.339v-426.504l31.455-4.629 352.892-65.97 359.784 65.97 23.017 4.629zM510.028 151.884l-4.211-0.844-302.89 51.476v269.101h307.102v-319.734zM815.434 471.634h-305.406v383.031c19.682-4.51 41.052-11.411 64.141-20.692 23.033-9.249 45.815-20.234 68.304-32.867 22.513-12.672 44.159-26.739 64.969-42.203 20.818-15.472 39.23-32.047 55.277-49.797 16.024-17.703 28.822-36.131 38.386-55.222 9.549-19.131 14.328-38.553 14.328-58.235v-124.015z" p-id="1851" fill="#ffffff"></path></svg>;
|
||||
|
||||
var hide_cm = undefined;
|
||||
@@ -35,6 +38,7 @@ class Body: Reactor.Component
|
||||
me.sendMsg(msg);
|
||||
};
|
||||
var right_style = show_chat ? "" : "display: none";
|
||||
var permissions_locked = !allow_perm_change_in_accept_window;
|
||||
var disconnected = c.disconnected;
|
||||
var show_elevation_btn = handler.can_elevate() && show_elevation && !c.is_file_transfer && !c.is_view_camera && !c.is_terminal && c.port_forward.length == 0;
|
||||
var show_accept_btn = handler.get_option('approve-mode') != 'password';
|
||||
@@ -58,15 +62,16 @@ class Body: Reactor.Component
|
||||
</div>
|
||||
<div />
|
||||
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" : <div>{translate('Permissions')}</div>}
|
||||
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" : <div> <div .permissions>
|
||||
{c.is_file_transfer || c.is_terminal || c.port_forward || disconnected ? "" : <div> <div class={permissions_locked ? "permissions locked" : "permissions"} style={permissions_locked ? "opacity:0.6;" : ""}>
|
||||
<div class={!c.keyboard ? "disabled" : ""} title={translate('Enable keyboard/mouse')}><icon .keyboard /></div>
|
||||
<div class={!c.clipboard ? "disabled" : ""} title={translate('Enable clipboard')}><icon .clipboard /></div>
|
||||
<div class={!c.audio ? "disabled" : ""} title={translate('Enable audio')}><icon .audio /></div>
|
||||
<div class={!c.file ? "disabled" : ""} title={translate('Enable file copy and paste')}><icon .file /></div>
|
||||
<div class={!c.restart ? "disabled" : ""} title={translate('Enable remote restart')}><icon .restart /></div>
|
||||
</div> <div .permissions style="margin-top:8px;" >
|
||||
</div> <div class={permissions_locked ? "permissions locked" : "permissions"} style={permissions_locked ? "margin-top:8px;opacity:0.6;" : "margin-top:8px;"} >
|
||||
<div class={!c.recording ? "disabled" : ""} title={translate('Enable recording session')}><icon .recording /></div>
|
||||
<div class={!c.block_input ? "disabled" : ""} title={translate('Enable blocking user input')} style={is_win ? "" : "display:none;"}><icon .block_input /></div>
|
||||
<div class={!c.privacy_mode ? "disabled" : ""} title={translate('Enable privacy mode')} style={is_privacy_mode_supported ? "" : "display:none;"}><icon .privacy_mode /></div>
|
||||
</div></div>
|
||||
}
|
||||
{c.is_file_transfer ? <div>{translate('Transfer file')}</div> : ""}
|
||||
@@ -103,6 +108,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.keyboard) (e) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.keyboard = !connection.keyboard;
|
||||
@@ -112,6 +118,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.clipboard) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.clipboard = !connection.clipboard;
|
||||
@@ -121,6 +128,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.audio) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.audio = !connection.audio;
|
||||
@@ -130,6 +138,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.file) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.file = !connection.file;
|
||||
@@ -139,6 +148,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.restart) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.restart = !connection.restart;
|
||||
@@ -148,6 +158,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.recording) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.recording = !connection.recording;
|
||||
@@ -157,6 +168,7 @@ class Body: Reactor.Component
|
||||
}
|
||||
|
||||
event click $(icon.block_input) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.block_input = !connection.block_input;
|
||||
@@ -165,6 +177,16 @@ class Body: Reactor.Component
|
||||
});
|
||||
}
|
||||
|
||||
event click $(icon.privacy_mode) {
|
||||
if (!allow_perm_change_in_accept_window) return;
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
connection.privacy_mode = !connection.privacy_mode;
|
||||
body.update();
|
||||
handler.switch_permission(cid, "privacy_mode", connection.privacy_mode);
|
||||
});
|
||||
}
|
||||
|
||||
event click $(button#accept) {
|
||||
var { cid, connection } = this;
|
||||
checkClickTime(function() {
|
||||
@@ -368,7 +390,7 @@ function bring_to_top(idx=-1) {
|
||||
}
|
||||
}
|
||||
|
||||
handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) {
|
||||
handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, privacy_mode) {
|
||||
stdout.println("new connection #" + id + ": " + peer_id);
|
||||
var conn;
|
||||
connections.map(function(c) {
|
||||
@@ -376,6 +398,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin
|
||||
});
|
||||
if (conn) {
|
||||
conn.authorized = authorized;
|
||||
conn.privacy_mode = privacy_mode;
|
||||
update();
|
||||
return;
|
||||
}
|
||||
@@ -391,7 +414,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin
|
||||
name: name, authorized: authorized, time: new Date(), now: new Date(),
|
||||
keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0,
|
||||
audio: audio, file: file, restart: restart, recording: recording,
|
||||
block_input:block_input,
|
||||
block_input:block_input, privacy_mode:privacy_mode,
|
||||
disconnected: false
|
||||
};
|
||||
if (idx < 0) {
|
||||
@@ -480,15 +503,21 @@ function getElapsed(time, now) {
|
||||
return out;
|
||||
}
|
||||
|
||||
var ui_status_cache = [""];
|
||||
var ui_status_cache = ["", ""];
|
||||
function check_update_ui() {
|
||||
self.timer(1s, function() {
|
||||
var approve_mode = handler.get_option('approve-mode');
|
||||
var allow_perm_change = handler.get_builtin_option('enable-perm-change-in-accept-window');
|
||||
var changed = false;
|
||||
if (ui_status_cache[0] != approve_mode) {
|
||||
ui_status_cache[0] = approve_mode;
|
||||
changed = true;
|
||||
}
|
||||
if (ui_status_cache[1] != allow_perm_change) {
|
||||
ui_status_cache[1] = allow_perm_change;
|
||||
allow_perm_change_in_accept_window = allow_perm_change != 'N';
|
||||
changed = true;
|
||||
}
|
||||
if (changed) update();
|
||||
check_update_ui();
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ class Header: Reactor.Component {
|
||||
{is_file_copy_paste_supported && file_enabled ? <li #enable-file-copy-paste .toggle-option><span>{svg_checkmark}</span>{translate('Enable file copy and paste')}</li> : ""}
|
||||
{keyboard_enabled && clipboard_enabled ? <li #disable-clipboard .toggle-option><span>{svg_checkmark}</span>{translate('Disable clipboard')}</li> : ""}
|
||||
{keyboard_enabled ? <li #lock-after-session-end .toggle-option><span>{svg_checkmark}</span>{translate('Lock after session end')}</li> : ""}
|
||||
{keyboard_enabled && pi.platform == "Windows" ? <li #privacy-mode><span>{svg_checkmark}</span>{translate('Privacy mode')}</li> : ""}
|
||||
{(pi.platform == "Windows" || pi.platform == "Mac OS") && (handler.get_toggle_option("privacy-mode") || (keyboard_enabled && privacy_mode_enabled)) ? <li #privacy-mode><span>{svg_checkmark}</span>{translate('Privacy mode')}</li> : ""}
|
||||
{keyboard_enabled && ((is_osx && pi.platform != "Mac OS") || (!is_osx && pi.platform == "Mac OS")) ? <li #allow_swap_key .toggle-option><span>{svg_checkmark}</span>{translate('Swap control-command key')}</li> : ""}
|
||||
{handler.version_cmp(pi.version, '1.2.4') >= 0 ? <li #i444><span>{svg_checkmark}</span>{translate('True color (4:4:4)')}</li> : ""}
|
||||
</menu>
|
||||
|
||||
@@ -521,6 +521,7 @@ class MyIdMenu: Reactor.Component {
|
||||
{!disable_settings && <li #enable-remote-restart><span>{svg_checkmark}</span>{translate('Enable remote restart')}</li>}
|
||||
{!disable_settings && <li #enable-tunnel><span>{svg_checkmark}</span>{translate('Enable TCP tunneling')}</li>}
|
||||
{!disable_settings && is_win ? <li #enable-block-input><span>{svg_checkmark}</span>{translate('Enable blocking user input')}</li> : ""}
|
||||
{!disable_settings && (handler.get_supported_privacy_mode_impls() != '[]') && <li #enable-privacy-mode><span>{svg_checkmark}</span>{translate('Enable privacy mode')}</li>}
|
||||
{!disable_settings && <li #enable-lan-discovery><span>{svg_checkmark}</span>{translate('Enable LAN discovery')}</li>}
|
||||
<AudioInputs />
|
||||
<Enhancements />
|
||||
|
||||
@@ -17,6 +17,7 @@ var audio_enabled = true; // server side
|
||||
var file_enabled = true; // server side
|
||||
var restart_enabled = true; // server side
|
||||
var recording_enabled = true; // server side
|
||||
var privacy_mode_enabled = true; // server side
|
||||
var scroll_body = $(body);
|
||||
var peer_platform = "";
|
||||
|
||||
@@ -588,6 +589,7 @@ handler.setPermission = function(name, enabled) {
|
||||
if (name == "clipboard") clipboard_enabled = enabled;
|
||||
if (name == "restart") restart_enabled = enabled;
|
||||
if (name == "recording") recording_enabled = enabled;
|
||||
if (name == "privacy_mode") privacy_mode_enabled = enabled;
|
||||
input_blocked = false;
|
||||
header.update();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,10 @@ use hbb_common::fs::serialize_transfer_job;
|
||||
use hbb_common::tokio::sync::mpsc::unbounded_channel;
|
||||
use hbb_common::{
|
||||
allow_err, bail,
|
||||
config::{keys::OPTION_FILE_TRANSFER_MAX_FILES, Config},
|
||||
config::{
|
||||
keys::{OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW, OPTION_FILE_TRANSFER_MAX_FILES},
|
||||
option2bool, Config,
|
||||
},
|
||||
fs::{self, get_string, is_write_need_confirmation, new_send_confirm, DigestCheckResult},
|
||||
log,
|
||||
message_proto::*,
|
||||
@@ -25,10 +28,7 @@ use hbb_common::{
|
||||
ResultType,
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
use hbb_common::{
|
||||
config::{keys::*, option2bool},
|
||||
tokio::sync::Mutex as TokioMutex,
|
||||
};
|
||||
use hbb_common::{config::keys::*, tokio::sync::Mutex as TokioMutex};
|
||||
use serde_derive::Serialize;
|
||||
#[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]
|
||||
use std::iter::FromIterator;
|
||||
@@ -143,6 +143,7 @@ pub struct Client {
|
||||
pub restart: bool,
|
||||
pub recording: bool,
|
||||
pub block_input: bool,
|
||||
pub privacy_mode: bool,
|
||||
pub from_switch: bool,
|
||||
pub in_voice_call: bool,
|
||||
pub incoming_voice_call: bool,
|
||||
@@ -230,6 +231,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
|
||||
restart: bool,
|
||||
recording: bool,
|
||||
block_input: bool,
|
||||
privacy_mode: bool,
|
||||
from_switch: bool,
|
||||
#[cfg(not(any(target_os = "ios")))] tx: mpsc::UnboundedSender<Data>,
|
||||
) {
|
||||
@@ -251,6 +253,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
|
||||
restart,
|
||||
recording,
|
||||
block_input,
|
||||
privacy_mode,
|
||||
from_switch,
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
tx,
|
||||
@@ -392,6 +395,23 @@ pub fn send_chat(id: i32, text: String) {
|
||||
#[inline]
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
pub fn switch_permission(id: i32, name: String, enabled: bool) {
|
||||
#[cfg(target_os = "android")]
|
||||
let is_keyboard_permission = name == "keyboard";
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let is_keyboard_permission = false;
|
||||
if !option2bool(
|
||||
OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
&crate::get_builtin_option(OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW),
|
||||
) && !is_keyboard_permission
|
||||
{
|
||||
log::info!(
|
||||
"blocked cm switch_permission by policy, conn_id={}, permission={}, enabled={}",
|
||||
id,
|
||||
name,
|
||||
enabled
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(client) = CLIENTS.read().unwrap().get(&id) {
|
||||
allow_err!(client.tx.send(Data::SwitchPermission { name, enabled }));
|
||||
};
|
||||
@@ -400,6 +420,19 @@ pub fn switch_permission(id: i32, name: String, enabled: bool) {
|
||||
#[inline]
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn switch_permission_all(name: String, enabled: bool) {
|
||||
if name != "keyboard"
|
||||
&& !option2bool(
|
||||
OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW,
|
||||
&crate::get_builtin_option(OPTION_ENABLE_PERM_CHANGE_IN_ACCEPT_WINDOW),
|
||||
)
|
||||
{
|
||||
log::info!(
|
||||
"blocked cm switch_permission_all by policy, permission={}, enabled={}",
|
||||
name,
|
||||
enabled
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (_, client) in CLIENTS.read().unwrap().iter() {
|
||||
allow_err!(client.tx.send(Data::SwitchPermission {
|
||||
name: name.clone(),
|
||||
@@ -422,9 +455,16 @@ pub fn get_clients_length() -> usize {
|
||||
clients.len()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn has_active_clients() -> bool {
|
||||
let clients = CLIENTS.read().unwrap();
|
||||
clients.values().any(|c| !c.disconnected)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
pub fn switch_back(id: i32) {
|
||||
if let Some(client) = CLIENTS.read().unwrap().get(&id) {
|
||||
allow_err!(client.tx.send(Data::SwitchSidesBack));
|
||||
@@ -503,9 +543,9 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
|
||||
}
|
||||
Ok(Some(data)) => {
|
||||
match data {
|
||||
Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => {
|
||||
Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, privacy_mode, from_switch} => {
|
||||
log::debug!("conn_id: {}", id);
|
||||
self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone());
|
||||
self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, privacy_mode, from_switch, self.tx.clone());
|
||||
self.conn_id = id;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -533,6 +573,26 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
|
||||
Data::ChatMessage { text } => {
|
||||
self.cm.new_message(self.conn_id, text);
|
||||
}
|
||||
Data::SwitchPermission { name, enabled } => {
|
||||
// Keep this branch scoped to privacy mode rollback.
|
||||
// Other CM permission toggles are updated optimistically by the UI itself.
|
||||
// The backend currently sends SwitchPermission back to CM only when
|
||||
// privacy-mode turn-off fails and the UI state must be restored.
|
||||
if name == "privacy_mode" {
|
||||
let client = {
|
||||
let mut clients = CLIENTS.write().unwrap();
|
||||
clients.get_mut(&self.conn_id).map(|c| {
|
||||
c.privacy_mode = enabled;
|
||||
c.clone()
|
||||
})
|
||||
};
|
||||
if let Some(client) = client {
|
||||
// This reuses add_connection(), and cm.tis only selectively updates
|
||||
// existing rows (authorized/privacy_mode) for this fallback path.
|
||||
self.cm.ui_handler.add_connection(&client);
|
||||
}
|
||||
}
|
||||
}
|
||||
Data::FS(mut fs) => {
|
||||
if let ipc::FS::WriteBlock { id, file_num, data: _, compressed } = fs {
|
||||
if let Ok(bytes) = self.stream.next_raw().await {
|
||||
@@ -835,6 +895,7 @@ pub async fn start_listen<T: InvokeUiCM>(
|
||||
restart,
|
||||
recording,
|
||||
block_input,
|
||||
privacy_mode,
|
||||
from_switch,
|
||||
..
|
||||
}) => {
|
||||
@@ -856,6 +917,7 @@ pub async fn start_listen<T: InvokeUiCM>(
|
||||
restart,
|
||||
recording,
|
||||
block_input,
|
||||
privacy_mode,
|
||||
from_switch,
|
||||
tx.clone(),
|
||||
);
|
||||
|
||||
@@ -1464,10 +1464,11 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
self.send(Data::ElevateWithLogon(username, password));
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "ios"))]
|
||||
#[cfg(any(target_os = "android", target_os = "ios", not(feature = "flutter")))]
|
||||
pub fn switch_sides(&self) {}
|
||||
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
#[cfg(feature = "flutter")]
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
pub async fn switch_sides(&self) {
|
||||
match crate::ipc::connect(1000, "").await {
|
||||
|
||||
Reference in New Issue
Block a user