Files
05b9f2d891 fix(flutter): keep Windows accept window side panels on the current monitor (#16855)
* fix(flutter): keep CM side panels on the current Windows monitor

Set the final physical position and size together when toggling CM chat or
file panels. This avoids an intermediate resize crossing into a monitor
with a different DPI before the position is applied.

Limit the change to the Windows CM path and add a positioning regression test.

* fix(flutter): fall back when the CM window screen is unavailable

getWindowInfo() could throw before the side-panel toggle reset its guard,
which locked the chat and file buttons and dropped the incoming message
that opened the panel. A failure now takes the previous setSizeAlignment
path, which also makes the existing no-screen fallback reachable.

The test now starts from the reported setup, the window at the right
edge of a 100% monitor, and checks that each toggle is a single
setBounds, since a resize followed by a move is what crossed monitors.
The 150% case keeps covering the scaled size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JAxkkiaGp2k8okvs4MpCUq

* test(flutter): cover CM window info fallback

* Revert "test(flutter): cover CM window info fallback"

This reverts commit 9204e6a021.

---------

Co-authored-by: rustdesk <71636191+rustdesk@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:09:43 +08:00

632 lines
18 KiB
Dart

import 'dart:async';
import 'package:dash_chat_2/dash_chat_2.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:draggable_float_widget/draggable_float_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/desktop/widgets/windows_cm_window_position.dart';
import 'package:flutter_hbb/mobile/pages/home_page.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:uuid/uuid.dart';
import 'package:window_manager/window_manager.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../consts.dart';
import '../common.dart';
import '../common/widgets/overlay.dart';
import '../main.dart';
import 'model.dart';
class MessageKey {
final String peerId;
final int connId;
bool get isOut => connId == ChatModel.clientModeID;
MessageKey(this.peerId, this.connId);
@override
bool operator ==(other) {
return other is MessageKey &&
other.peerId == peerId &&
other.isOut == isOut;
}
@override
int get hashCode => peerId.hashCode ^ isOut.hashCode;
}
class MessageBody {
ChatUser chatUser;
List<ChatMessage> chatMessages;
MessageBody(this.chatUser, this.chatMessages);
void insert(ChatMessage cm) {
chatMessages.insert(0, cm);
}
void clear() {
chatMessages.clear();
}
}
class ChatModel with ChangeNotifier {
static final clientModeID = -1;
OverlayEntry? chatIconOverlayEntry;
OverlayEntry? chatWindowOverlayEntry;
bool isConnManager = false;
RxBool isWindowFocus = true.obs;
BlockableOverlayState _blockableOverlayState = BlockableOverlayState();
final Rx<VoiceCallStatus> _voiceCallStatus = Rx(VoiceCallStatus.notStarted);
final RxBool _softKeyboardRequested = false.obs;
bool _usingPhysicalChatInput = false;
Rx<VoiceCallStatus> get voiceCallStatus => _voiceCallStatus;
RxBool get softKeyboardRequested => _softKeyboardRequested;
TextEditingController textController = TextEditingController();
RxInt mobileUnreadSum = 0.obs;
MessageKey? latestReceivedKey;
Offset chatWindowPosition = Offset(20, 80);
void setChatWindowPosition(Offset position) {
chatWindowPosition = position;
notifyListeners();
}
@override
void dispose() {
inputNode.removeListener(_updateChatInputControl);
_restoreChatInputControl();
textController.dispose();
super.dispose();
}
final ChatUser me = ChatUser(
id: Uuid().v4().toString(),
firstName: translate("Me"),
);
late final Map<MessageKey, MessageBody> _messages = {};
MessageKey _currentKey = MessageKey('', -2); // -2 is invalid value
late bool _isShowCMSidePage = false;
Map<MessageKey, MessageBody> get messages => _messages;
MessageKey get currentKey => _currentKey;
bool get isShowCMSidePage => _isShowCMSidePage;
void setOverlayState(BlockableOverlayState blockableOverlayState) {
_blockableOverlayState = blockableOverlayState;
_blockableOverlayState.addMiddleBlockedListener((v) {
if (!v) {
isWindowFocus.value = false;
if (isWindowFocus.value) {
isWindowFocus.toggle();
}
}
});
}
final WeakReference<FFI> parent;
late final SessionID sessionId;
late FocusNode inputNode;
ChatModel(this.parent) {
sessionId = parent.target!.sessionId;
inputNode = FocusNode(
onKey: (_, event) {
bool isShiftPressed = event.isKeyPressed(LogicalKeyboardKey.shiftLeft);
bool isEnterPressed = event.isKeyPressed(LogicalKeyboardKey.enter);
// don't send empty messages
if (isEnterPressed && isEnterPressed && textController.text.isEmpty) {
return KeyEventResult.handled;
}
if (isEnterPressed && !isShiftPressed) {
final ChatMessage message = ChatMessage(
text: textController.text,
user: me,
createdAt: DateTime.now(),
);
send(message);
textController.clear();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
}
ChatUser? get currentUser => _messages[_currentKey]?.chatUser;
showChatIconOverlay({Offset offset = const Offset(200, 50)}) {
if (chatIconOverlayEntry != null) {
chatIconOverlayEntry!.remove();
}
// mobile check navigationBar
final bar = navigationBarKey.currentWidget;
if (bar != null) {
if ((bar as BottomNavigationBar).currentIndex == 1) {
return;
}
}
final overlayState = _blockableOverlayState.state;
if (overlayState == null) return;
final overlay = OverlayEntry(builder: (context) {
return DraggableFloatWidget(
config: DraggableFloatWidgetBaseConfig(
initPositionYInTop: false,
initPositionYMarginBorder: 100,
borderTopContainTopBar: true,
),
child: FloatingActionButton(
onPressed: () {
if (chatWindowOverlayEntry == null) {
showChatWindowOverlay();
} else {
hideChatWindowOverlay();
}
},
backgroundColor: Theme.of(context).colorScheme.primary,
child: SvgPicture.asset('assets/chat2.svg'),
),
);
});
overlayState.insert(overlay);
chatIconOverlayEntry = overlay;
}
hideChatIconOverlay() {
if (chatIconOverlayEntry != null) {
chatIconOverlayEntry!.remove();
chatIconOverlayEntry = null;
}
}
showChatWindowOverlay(
{Offset? chatInitPos, bool requestSoftKeyboard = true}) {
if (chatWindowOverlayEntry != null) return;
isWindowFocus.value = true;
_blockableOverlayState.setMiddleBlocked(true);
final overlayState = _blockableOverlayState.state;
if (overlayState == null) return;
if (isMobile) {
_softKeyboardRequested.value = requestSoftKeyboard;
gFFI.invokeMethod("enable_soft_keyboard", true);
}
if (isMobile &&
!gFFI.chatModel.currentKey.isOut && // not in remote page
gFFI.chatModel.latestReceivedKey != null) {
gFFI.chatModel.changeCurrentKey(gFFI.chatModel.latestReceivedKey!);
gFFI.chatModel.mobileClearClientUnread(gFFI.chatModel.currentKey.connId);
}
final overlay = OverlayEntry(builder: (context) {
return Listener(
onPointerDown: (event) {
_requestChatSoftKeyboard(event);
if (!isWindowFocus.value) {
isWindowFocus.value = true;
_blockableOverlayState.setMiddleBlocked(true);
}
},
child: DraggableChatWindow(
position: chatInitPos ?? chatWindowPosition,
width: 250,
height: 350,
chatModel: this));
});
overlayState.insert(overlay);
chatWindowOverlayEntry = overlay;
if (isMobile && !requestSoftKeyboard) {
inputNode.addListener(_updateChatInputControl);
_updateChatInputControl();
}
requestChatInputFocus();
}
hideChatWindowOverlay() {
if (chatWindowOverlayEntry != null) {
_blockableOverlayState.setMiddleBlocked(false);
chatWindowOverlayEntry!.remove();
chatWindowOverlayEntry = null;
_softKeyboardRequested.value = false;
inputNode.removeListener(_updateChatInputControl);
_restoreChatInputControl();
return;
}
}
_isChatOverlayHide() =>
((!(isDesktop || isWebDesktop) && chatIconOverlayEntry == null) ||
chatWindowOverlayEntry == null);
toggleChatOverlay({Offset? chatInitPos, bool requestSoftKeyboard = true}) {
if (_isChatOverlayHide()) {
if (!isMobile) gFFI.invokeMethod("enable_soft_keyboard", true);
if (!(isDesktop || isWebDesktop)) {
showChatIconOverlay();
}
showChatWindowOverlay(
chatInitPos: chatInitPos, requestSoftKeyboard: requestSoftKeyboard);
} else {
hideChatIconOverlay();
hideChatWindowOverlay();
}
}
hideChatOverlay() {
if (!_isChatOverlayHide()) {
hideChatIconOverlay();
hideChatWindowOverlay();
}
}
showChatPage(MessageKey key) async {
if (isDesktop) {
if (isConnManager) {
if (!_isShowCMSidePage) {
await toggleCMChatPage(key);
}
} else {
if (_isChatOverlayHide()) {
await toggleChatOverlay();
}
}
} else {
if (key.connId == clientModeID) {
if (_isChatOverlayHide()) {
await toggleChatOverlay();
}
}
}
}
toggleCMChatPage(MessageKey key) async {
if (gFFI.chatModel.currentKey != key) {
gFFI.chatModel.changeCurrentKey(key);
}
await toggleCMSidePage();
}
toggleCMFilePage() async {
await toggleCMSidePage();
}
var _togglingCMSidePage = false; // protect order for await
toggleCMSidePage() async {
if (_togglingCMSidePage) return false;
_togglingCMSidePage = true;
if (_isShowCMSidePage) {
_isShowCMSidePage = !_isShowCMSidePage;
notifyListeners();
await windowManager.show();
if (isWindows) {
await setWindowsCmWindowBounds(kConnectionManagerWindowSizeClosedChat);
} else {
await windowManager.setSizeAlignment(
kConnectionManagerWindowSizeClosedChat, Alignment.topRight);
}
} else {
final currentSelectedTab =
gFFI.serverModel.tabController.state.value.selectedTabInfo;
final client = parent.target?.serverModel.clients.firstWhereOrNull(
(client) => client.id.toString() == currentSelectedTab.key);
if (client != null) {
client.unreadChatMessageCount.value = 0;
}
requestChatInputFocus();
await windowManager.show();
if (isWindows) {
await setWindowsCmWindowBounds(kConnectionManagerWindowSizeOpenChat);
} else {
await windowManager.setSizeAlignment(
kConnectionManagerWindowSizeOpenChat, Alignment.topRight);
}
_isShowCMSidePage = !_isShowCMSidePage;
notifyListeners();
}
_togglingCMSidePage = false;
}
changeCurrentKey(MessageKey key) {
updateConnIdOfKey(key);
String? peerName;
if (key.connId == clientModeID) {
peerName = parent.target?.ffiModel.pi.username;
} else {
peerName = parent.target?.serverModel.clients
.firstWhereOrNull((client) => client.peerId == key.peerId)
?.name;
}
if (!_messages.containsKey(key)) {
final chatUser = ChatUser(
id: key.peerId,
firstName: peerName,
);
_messages[key] = MessageBody(chatUser, []);
} else {
if (peerName != null && peerName.isNotEmpty) {
_messages[key]?.chatUser.firstName = peerName;
}
}
_currentKey = key;
notifyListeners();
mobileClearClientUnread(key.connId);
}
receive(int id, String text) async {
final session = parent.target;
if (session == null) {
debugPrint("Failed to receive msg, session state is null");
return;
}
if (text.isEmpty) return;
if (desktopType == DesktopType.cm) {
await showCmWindow();
}
String? peerId;
if (id == clientModeID) {
peerId = session.id;
} else {
peerId = session.serverModel.clients
.firstWhereOrNull((e) => e.id == id)
?.peerId;
}
if (peerId == null) {
debugPrint("Failed to receive msg, peerId is null");
return;
}
final messagekey = MessageKey(peerId, id);
// mobile: first message show overlay icon
if (!isDesktop && chatIconOverlayEntry == null) {
showChatIconOverlay();
}
// show chat page
await showChatPage(messagekey);
late final ChatUser chatUser;
if (id == clientModeID) {
chatUser = ChatUser(
firstName: session.ffiModel.pi.username,
id: peerId,
);
if (isDesktop) {
if (Get.isRegistered<DesktopTabController>()) {
DesktopTabController tabController = Get.find<DesktopTabController>();
var index = tabController.state.value.tabs
.indexWhere((e) => e.key == session.id);
final notSelected =
index >= 0 && tabController.state.value.selected != index;
// minisized: top and switch tab
// not minisized: add count
if (await WindowController.fromWindowId(stateGlobal.windowId)
.isMinimized()) {
windowOnTop(stateGlobal.windowId);
if (notSelected) {
tabController.jumpTo(index);
}
} else {
if (notSelected) {
UnreadChatCountState.find(peerId).value += 1;
}
}
}
}
} else {
final client = session.serverModel.clients
.firstWhereOrNull((client) => client.id == id);
if (client == null) {
debugPrint("Failed to receive msg, client is null");
return;
}
if (isDesktop) {
windowOnTop(null);
// disable auto jumpTo other tab when hasFocus, and mark unread message
final currentSelectedTab =
session.serverModel.tabController.state.value.selectedTabInfo;
if (currentSelectedTab.key != id.toString() && inputNode.hasFocus) {
client.unreadChatMessageCount.value += 1;
} else {
parent.target?.serverModel.jumpTo(id);
}
} else {
if (HomePage.homeKey.currentState?.isChatPageCurrentTab != true ||
_currentKey != messagekey) {
client.unreadChatMessageCount.value += 1;
mobileUpdateUnreadSum();
}
}
chatUser = ChatUser(id: client.peerId, firstName: client.name);
}
insertMessage(messagekey,
ChatMessage(text: text, user: chatUser, createdAt: DateTime.now()));
if (id == clientModeID || _currentKey.peerId.isEmpty) {
// client or invalid
_currentKey = messagekey;
mobileClearClientUnread(messagekey.connId);
}
latestReceivedKey = messagekey;
notifyListeners();
}
send(ChatMessage message) {
String trimmedText = message.text.trim();
if (trimmedText.isEmpty) {
return;
}
message.text = trimmedText;
insertMessage(_currentKey, message);
if (_currentKey.connId == clientModeID && parent.target != null) {
bind.sessionSendChat(sessionId: sessionId, text: message.text);
} else {
bind.cmSendChat(connId: _currentKey.connId, msg: message.text);
}
notifyListeners();
inputNode.requestFocus();
}
insertMessage(MessageKey key, ChatMessage message) {
updateConnIdOfKey(key);
if (!_messages.containsKey(key)) {
_messages[key] = MessageBody(message.user, []);
}
_messages[key]?.insert(message);
}
updateConnIdOfKey(MessageKey key) {
if (_messages.keys
.toList()
.firstWhereOrNull((e) => e == key && e.connId != key.connId) !=
null) {
final value = _messages.remove(key);
if (value != null) {
_messages[key] = value;
}
}
if (_currentKey == key || _currentKey.peerId.isEmpty) {
_currentKey = key; // hash != assign
}
}
void mobileUpdateUnreadSum() {
if (!isMobile) return;
var sum = 0;
parent.target?.serverModel.clients
.map((e) => sum += e.unreadChatMessageCount.value)
.toList();
Future.delayed(Duration.zero, () {
mobileUnreadSum.value = sum;
});
}
void mobileClearClientUnread(int id) {
if (!isMobile) return;
final client = parent.target?.serverModel.clients
.firstWhereOrNull((client) => client.id == id);
if (client != null) {
Future.delayed(Duration.zero, () {
client.unreadChatMessageCount.value = 0;
mobileUpdateUnreadSum();
});
}
}
close() {
hideChatIconOverlay();
hideChatWindowOverlay();
notifyListeners();
}
resetClientMode() {
_messages[clientModeID]?.clear();
}
void requestChatInputFocus() {
Timer(Duration(milliseconds: 100), () {
if (inputNode.hasListeners && inputNode.canRequestFocus) {
inputNode.requestFocus();
}
});
}
void _requestChatSoftKeyboard(PointerDownEvent event) {
if (!isMobile || _softKeyboardRequested.value) return;
inputNode.context?.visitAncestorElements((element) {
if (element.widget is! TextField) return true;
final box = element.findRenderObject();
if (box is! RenderBox) return false;
final bounds = box.localToGlobal(Offset.zero) & box.size;
if (!bounds.contains(event.position)) return false;
_softKeyboardRequested.value = true;
_restoreChatInputControl();
// Reconnect so the platform receives the keyboard type instead of none.
inputNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild);
scheduleMicrotask(() {
if (chatWindowOverlayEntry != null && _softKeyboardRequested.value) {
inputNode.requestFocus();
}
});
return false;
});
}
void _updateChatInputControl() {
final physicalChatFocused = chatWindowOverlayEntry != null &&
inputNode.hasFocus &&
!_softKeyboardRequested.value;
if (!physicalChatFocused) {
_restoreChatInputControl();
return;
}
if (_usingPhysicalChatInput) return;
_usingPhysicalChatInput = true;
// Keep the platform text-input connection for physical editing, without IME UI.
TextInput.setInputControl(null);
}
void _restoreChatInputControl() {
if (!_usingPhysicalChatInput) return;
_usingPhysicalChatInput = false;
TextInput.restorePlatformInputControl();
}
void onVoiceCallWaiting() {
_voiceCallStatus.value = VoiceCallStatus.waitingForResponse;
}
void onVoiceCallStarted() {
_voiceCallStatus.value = VoiceCallStatus.connected;
if (isAndroid) {
parent.target?.invokeMethod("on_voice_call_started");
}
}
void onVoiceCallClosed(String reason) {
_voiceCallStatus.value = VoiceCallStatus.notStarted;
if (isAndroid) {
// We can always invoke "on_voice_call_closed"
// no matter if the `_voiceCallStatus` was `VoiceCallStatus.notStarted` or not.
parent.target?.invokeMethod("on_voice_call_closed");
}
}
void onVoiceCallIncoming() {
if (isConnManager) {
_voiceCallStatus.value = VoiceCallStatus.incoming;
}
}
void closeVoiceCall() {
bind.sessionCloseVoiceCall(sessionId: sessionId);
}
}
enum VoiceCallStatus {
notStarted,
waitingForResponse,
connected,
// Connection manager only.
incoming
}