Initial commit: RustDesk 1.4.7 macOS desktop port
- Filled empty libs/hbb_common/ submodule (cloned from rustdesk/hbb_common) - Patched Flutter 3.44 / Dart 3.12 compatibility: * flutter/lib/generated_bridge.dart: asTypedList with cast<>, DartPort=Int64 * flutter/lib/common.dart: DialogTheme->DialogThemeData, TabBarTheme->TabBarThemeData * flutter/pubspec.yaml: extended_text 14.0.0->15.0.2, google_fonts override 5.0.0 * flutter/macos/Runner/Configs/Release.xcconfig: EXCLUDED_ARCHS=x86_64 - Build verified: cargo check + cargo build --features flutter + cargo build --release --features flutter - Verified flutter build macos --debug and --release both produce working .app - Verified .dmg installer (27MB arm64) created via hdiutil - Build deps: Xcode 26.5, CocoaPods 1.16.2, Flutter 3.44, VCPKG arm64-osx
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
||||
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/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);
|
||||
|
||||
Rx<VoiceCallStatus> get voiceCallStatus => _voiceCallStatus;
|
||||
|
||||
TextEditingController textController = TextEditingController();
|
||||
RxInt mobileUnreadSum = 0.obs;
|
||||
MessageKey? latestReceivedKey;
|
||||
|
||||
Offset chatWindowPosition = Offset(20, 80);
|
||||
|
||||
void setChatWindowPosition(Offset position) {
|
||||
chatWindowPosition = position;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
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}) {
|
||||
if (chatWindowOverlayEntry != null) return;
|
||||
isWindowFocus.value = true;
|
||||
_blockableOverlayState.setMiddleBlocked(true);
|
||||
|
||||
final overlayState = _blockableOverlayState.state;
|
||||
if (overlayState == null) return;
|
||||
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: (_) {
|
||||
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;
|
||||
requestChatInputFocus();
|
||||
}
|
||||
|
||||
hideChatWindowOverlay() {
|
||||
if (chatWindowOverlayEntry != null) {
|
||||
_blockableOverlayState.setMiddleBlocked(false);
|
||||
chatWindowOverlayEntry!.remove();
|
||||
chatWindowOverlayEntry = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_isChatOverlayHide() =>
|
||||
((!(isDesktop || isWebDesktop) && chatIconOverlayEntry == null) ||
|
||||
chatWindowOverlayEntry == null);
|
||||
|
||||
toggleChatOverlay({Offset? chatInitPos}) {
|
||||
if (_isChatOverlayHide()) {
|
||||
gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
if (!(isDesktop || isWebDesktop)) {
|
||||
showChatIconOverlay();
|
||||
}
|
||||
showChatWindowOverlay(chatInitPos: chatInitPos);
|
||||
} 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();
|
||||
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();
|
||||
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 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
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/server_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'file_model.dart';
|
||||
|
||||
class CmFileModel {
|
||||
final WeakReference<FFI> parent;
|
||||
final currentJobTable = RxList<CmFileLog>();
|
||||
final _jobTables = HashMap<int, RxList<CmFileLog>>.fromEntries([]);
|
||||
Stopwatch stopwatch = Stopwatch();
|
||||
int _lastElapsed = 0;
|
||||
|
||||
CmFileModel(this.parent);
|
||||
|
||||
void updateCurrentClientId(int id) {
|
||||
if (_jobTables[id] == null) {
|
||||
_jobTables[id] = RxList<CmFileLog>();
|
||||
}
|
||||
Future.delayed(Duration.zero, () {
|
||||
currentJobTable.value = _jobTables[id]!;
|
||||
});
|
||||
}
|
||||
|
||||
onFileTransferLog(Map<String, dynamic> evt) {
|
||||
if (evt['transfer'] != null) {
|
||||
_onFileTransfer(evt['transfer']);
|
||||
} else if (evt['remove'] != null) {
|
||||
_onFileRemove(evt['remove']);
|
||||
} else if (evt['create_dir'] != null) {
|
||||
_onDirCreate(evt['create_dir']);
|
||||
} else if (evt['rename'] != null) {
|
||||
_onRename(evt['rename']);
|
||||
}
|
||||
}
|
||||
|
||||
_onFileTransfer(dynamic log) {
|
||||
try {
|
||||
dynamic d = jsonDecode(log);
|
||||
if (!stopwatch.isRunning) stopwatch.start();
|
||||
bool calcSpeed = stopwatch.elapsedMilliseconds - _lastElapsed >= 1000;
|
||||
if (calcSpeed) {
|
||||
_lastElapsed = stopwatch.elapsedMilliseconds;
|
||||
}
|
||||
if (d is List<dynamic>) {
|
||||
for (var l in d) {
|
||||
_dealOneJob(l, calcSpeed);
|
||||
}
|
||||
} else {
|
||||
_dealOneJob(d, calcSpeed);
|
||||
}
|
||||
currentJobTable.refresh();
|
||||
} catch (e) {
|
||||
debugPrint("onFileTransferLog:$e");
|
||||
}
|
||||
}
|
||||
|
||||
_dealOneJob(dynamic l, bool calcSpeed) {
|
||||
final data = TransferJobSerdeData.fromJson(l);
|
||||
var jobTable = _jobTables[data.connId];
|
||||
if (jobTable == null) {
|
||||
debugPrint("jobTable should not be null");
|
||||
return;
|
||||
}
|
||||
CmFileLog? job = jobTable.firstWhereOrNull((e) => e.id == data.id);
|
||||
if (job == null) {
|
||||
job = CmFileLog();
|
||||
jobTable.add(job);
|
||||
_addUnread(data.connId);
|
||||
}
|
||||
job.id = data.id;
|
||||
job.action =
|
||||
data.isRemote ? CmFileAction.remoteToLocal : CmFileAction.localToRemote;
|
||||
job.fileName = data.path;
|
||||
job.totalSize = data.totalSize;
|
||||
job.finishedSize = data.finishedSize;
|
||||
if (job.finishedSize > data.totalSize) {
|
||||
job.finishedSize = data.totalSize;
|
||||
}
|
||||
|
||||
if (job.finishedSize > 0) {
|
||||
if (job.finishedSize < job.totalSize) {
|
||||
job.state = JobState.inProgress;
|
||||
} else {
|
||||
job.state = JobState.done;
|
||||
}
|
||||
}
|
||||
if (data.done) {
|
||||
job.state = JobState.done;
|
||||
} else if (data.cancel || data.error == 'skipped') {
|
||||
job.state = JobState.done;
|
||||
job.err = 'skipped';
|
||||
} else if (data.error.isNotEmpty) {
|
||||
job.state = JobState.error;
|
||||
job.err = data.error;
|
||||
}
|
||||
if (calcSpeed) {
|
||||
job.speed = (data.transferred - job.lastTransferredSize) * 1.0;
|
||||
job.lastTransferredSize = data.transferred;
|
||||
}
|
||||
jobTable.refresh();
|
||||
}
|
||||
|
||||
_onFileRemove(dynamic log) {
|
||||
try {
|
||||
dynamic d = jsonDecode(log);
|
||||
FileActionLog data = FileActionLog.fromJson(d);
|
||||
Client? client =
|
||||
gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == data.connId);
|
||||
var jobTable = _jobTables[data.connId];
|
||||
if (jobTable == null) {
|
||||
debugPrint("jobTable should not be null");
|
||||
return;
|
||||
}
|
||||
int removeUnreadCount = 0;
|
||||
if (data.dir) {
|
||||
bool isChild(String parent, String child) {
|
||||
if (child.startsWith(parent) && child.length > parent.length) {
|
||||
final suffix = child.substring(parent.length);
|
||||
return suffix.startsWith('/') || suffix.startsWith('\\');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
removeUnreadCount = jobTable
|
||||
.where((e) =>
|
||||
e.action == CmFileAction.remove &&
|
||||
isChild(data.path, e.fileName))
|
||||
.length;
|
||||
jobTable.removeWhere((e) =>
|
||||
e.action == CmFileAction.remove && isChild(data.path, e.fileName));
|
||||
}
|
||||
jobTable.add(CmFileLog()
|
||||
..id = data.id
|
||||
..fileName = data.path
|
||||
..action = CmFileAction.remove
|
||||
..state = JobState.done);
|
||||
final currentSelectedTab =
|
||||
gFFI.serverModel.tabController.state.value.selectedTabInfo;
|
||||
if (!(gFFI.chatModel.isShowCMSidePage &&
|
||||
currentSelectedTab.key == data.connId.toString())) {
|
||||
// Wrong number if unreadCount changes during deletion, which rarely happens
|
||||
RxInt? rx = client?.unreadChatMessageCount;
|
||||
if (rx != null) {
|
||||
if (rx.value >= removeUnreadCount) {
|
||||
rx.value -= removeUnreadCount;
|
||||
}
|
||||
rx.value += 1;
|
||||
}
|
||||
}
|
||||
jobTable.refresh();
|
||||
} catch (e) {
|
||||
debugPrint('$e');
|
||||
}
|
||||
}
|
||||
|
||||
_onDirCreate(dynamic log) {
|
||||
try {
|
||||
dynamic d = jsonDecode(log);
|
||||
FileActionLog data = FileActionLog.fromJson(d);
|
||||
var jobTable = _jobTables[data.connId];
|
||||
if (jobTable == null) {
|
||||
debugPrint("jobTable should not be null");
|
||||
return;
|
||||
}
|
||||
jobTable.add(CmFileLog()
|
||||
..id = data.id
|
||||
..fileName = data.path
|
||||
..action = CmFileAction.createDir
|
||||
..state = JobState.done);
|
||||
_addUnread(data.connId);
|
||||
jobTable.refresh();
|
||||
} catch (e) {
|
||||
debugPrint('$e');
|
||||
}
|
||||
}
|
||||
|
||||
_onRename(dynamic log) {
|
||||
try {
|
||||
dynamic d = jsonDecode(log);
|
||||
FileRenamenLog data = FileRenamenLog.fromJson(d);
|
||||
var jobTable = _jobTables[data.connId];
|
||||
if (jobTable == null) {
|
||||
debugPrint("jobTable should not be null");
|
||||
return;
|
||||
}
|
||||
final fileName = '${data.path} -> ${data.newName}';
|
||||
jobTable.add(CmFileLog()
|
||||
..id = 0
|
||||
..fileName = fileName
|
||||
..action = CmFileAction.rename
|
||||
..state = JobState.done);
|
||||
_addUnread(data.connId);
|
||||
jobTable.refresh();
|
||||
} catch (e) {
|
||||
debugPrint('$e');
|
||||
}
|
||||
}
|
||||
|
||||
_addUnread(int connId) {
|
||||
Client? client =
|
||||
gFFI.serverModel.clients.firstWhereOrNull((e) => e.id == connId);
|
||||
final currentSelectedTab =
|
||||
gFFI.serverModel.tabController.state.value.selectedTabInfo;
|
||||
if (!(gFFI.chatModel.isShowCMSidePage &&
|
||||
currentSelectedTab.key == connId.toString())) {
|
||||
client?.unreadChatMessageCount.value += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CmFileAction {
|
||||
none,
|
||||
remoteToLocal,
|
||||
localToRemote,
|
||||
remove,
|
||||
createDir,
|
||||
rename,
|
||||
}
|
||||
|
||||
class CmFileLog {
|
||||
JobState state = JobState.none;
|
||||
var id = 0;
|
||||
var speed = 0.0;
|
||||
var finishedSize = 0;
|
||||
var totalSize = 0;
|
||||
CmFileAction action = CmFileAction.none;
|
||||
var fileName = "";
|
||||
var err = "";
|
||||
int lastTransferredSize = 0;
|
||||
|
||||
String display() {
|
||||
if (state == JobState.done && err == "skipped") {
|
||||
return translate("Skipped");
|
||||
}
|
||||
return state.display();
|
||||
}
|
||||
|
||||
bool isTransfer() {
|
||||
return action == CmFileAction.remoteToLocal ||
|
||||
action == CmFileAction.localToRemote;
|
||||
}
|
||||
}
|
||||
|
||||
class TransferJobSerdeData {
|
||||
int connId;
|
||||
int id;
|
||||
String path;
|
||||
bool isRemote;
|
||||
int totalSize;
|
||||
int finishedSize;
|
||||
int transferred;
|
||||
bool done;
|
||||
bool cancel;
|
||||
String error;
|
||||
|
||||
TransferJobSerdeData({
|
||||
required this.connId,
|
||||
required this.id,
|
||||
required this.path,
|
||||
required this.isRemote,
|
||||
required this.totalSize,
|
||||
required this.finishedSize,
|
||||
required this.transferred,
|
||||
required this.done,
|
||||
required this.cancel,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
TransferJobSerdeData.fromJson(dynamic d)
|
||||
: this(
|
||||
connId: d['connId'] ?? 0,
|
||||
id: int.tryParse(d['id'].toString()) ?? 0,
|
||||
path: d['dataSource'] ?? '',
|
||||
isRemote: d['isRemote'] ?? false,
|
||||
totalSize: d['totalSize'] ?? 0,
|
||||
finishedSize: d['finishedSize'] ?? 0,
|
||||
transferred: d['transferred'] ?? 0,
|
||||
done: d['done'] ?? false,
|
||||
cancel: d['cancel'] ?? false,
|
||||
error: d['error'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class FileActionLog {
|
||||
int id = 0;
|
||||
int connId = 0;
|
||||
String path = '';
|
||||
bool dir = false;
|
||||
|
||||
FileActionLog({
|
||||
required this.connId,
|
||||
required this.id,
|
||||
required this.path,
|
||||
required this.dir,
|
||||
});
|
||||
|
||||
FileActionLog.fromJson(dynamic d)
|
||||
: this(
|
||||
connId: d['connId'] ?? 0,
|
||||
id: d['id'] ?? 0,
|
||||
path: d['path'] ?? '',
|
||||
dir: d['dir'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
class FileRenamenLog {
|
||||
int connId = 0;
|
||||
String path = '';
|
||||
String newName = '';
|
||||
|
||||
FileRenamenLog({
|
||||
required this.connId,
|
||||
required this.path,
|
||||
required this.newName,
|
||||
});
|
||||
|
||||
FileRenamenLog.fromJson(dynamic d)
|
||||
: this(
|
||||
connId: d['connId'] ?? 0,
|
||||
path: d['path'] ?? '',
|
||||
newName: d['newName'] ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gpu_texture_renderer/flutter_gpu_texture_renderer.dart';
|
||||
import 'package:flutter_hbb/common/shared_state.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import './platform_model.dart';
|
||||
|
||||
import 'package:texture_rgba_renderer/texture_rgba_renderer.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/texture_rgba_renderer.dart';
|
||||
|
||||
class _PixelbufferTexture {
|
||||
int _textureKey = -1;
|
||||
int _display = 0;
|
||||
SessionID? _sessionId;
|
||||
bool _destroying = false;
|
||||
int? _id;
|
||||
|
||||
final textureRenderer = TextureRgbaRenderer();
|
||||
|
||||
int get display => _display;
|
||||
|
||||
create(int d, SessionID sessionId, FFI ffi) {
|
||||
_display = d;
|
||||
_textureKey = bind.getNextTextureKey();
|
||||
_sessionId = sessionId;
|
||||
|
||||
textureRenderer.createTexture(_textureKey).then((id) async {
|
||||
_id = id;
|
||||
if (id != -1) {
|
||||
ffi.textureModel.setRgbaTextureId(display: d, id: id);
|
||||
final ptr = await textureRenderer.getTexturePtr(_textureKey);
|
||||
platformFFI.registerPixelbufferTexture(sessionId, display, ptr);
|
||||
debugPrint(
|
||||
"create pixelbuffer texture: peerId: ${ffi.id} display:$_display, textureId:$id, texturePtr:$ptr");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy(bool unregisterTexture, FFI ffi) async {
|
||||
if (!_destroying && _textureKey != -1 && _sessionId != null) {
|
||||
_destroying = true;
|
||||
if (unregisterTexture) {
|
||||
platformFFI.registerPixelbufferTexture(_sessionId!, display, 0);
|
||||
// sleep for a while to avoid the texture is used after it's unregistered.
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
}
|
||||
await textureRenderer.closeTexture(_textureKey);
|
||||
_textureKey = -1;
|
||||
_destroying = false;
|
||||
debugPrint(
|
||||
"destroy pixelbuffer texture: peerId: ${ffi.id} display:$_display, textureId:$_id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _GpuTexture {
|
||||
int _textureId = -1;
|
||||
SessionID? _sessionId;
|
||||
final support = bind.mainHasGpuTextureRender();
|
||||
bool _destroying = false;
|
||||
int _display = 0;
|
||||
int? _id;
|
||||
int? _output;
|
||||
|
||||
int get display => _display;
|
||||
|
||||
final gpuTextureRenderer = FlutterGpuTextureRenderer();
|
||||
|
||||
_GpuTexture();
|
||||
|
||||
create(int d, SessionID sessionId, FFI ffi) {
|
||||
if (support) {
|
||||
_sessionId = sessionId;
|
||||
_display = d;
|
||||
|
||||
gpuTextureRenderer.registerTexture().then((id) async {
|
||||
_id = id;
|
||||
if (id != null) {
|
||||
_textureId = id;
|
||||
ffi.textureModel.setGpuTextureId(display: d, id: id);
|
||||
final output = await gpuTextureRenderer.output(id);
|
||||
_output = output;
|
||||
if (output != null) {
|
||||
platformFFI.registerGpuTexture(sessionId, d, output);
|
||||
}
|
||||
debugPrint(
|
||||
"create gpu texture: peerId: ${ffi.id} display:$_display, textureId:$id, output:$output");
|
||||
}
|
||||
}, onError: (err) {
|
||||
debugPrint("Failed to register gpu texture:$err");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
destroy(bool unregisterTexture, FFI ffi) async {
|
||||
// must stop texture render, render unregistered texture cause crash
|
||||
if (!_destroying && support && _sessionId != null && _textureId != -1) {
|
||||
_destroying = true;
|
||||
if (unregisterTexture) {
|
||||
platformFFI.registerGpuTexture(_sessionId!, _display, 0);
|
||||
// sleep for a while to avoid the texture is used after it's unregistered.
|
||||
await Future.delayed(Duration(milliseconds: 100));
|
||||
}
|
||||
await gpuTextureRenderer.unregisterTexture(_textureId);
|
||||
_textureId = -1;
|
||||
_destroying = false;
|
||||
debugPrint(
|
||||
"destroy gpu texture: peerId: ${ffi.id} display:$_display, textureId:$_id, output:$_output");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Control {
|
||||
RxInt textureID = (-1).obs;
|
||||
|
||||
int _rgbaTextureId = -1;
|
||||
int get rgbaTextureId => _rgbaTextureId;
|
||||
int _gpuTextureId = -1;
|
||||
int get gpuTextureId => _gpuTextureId;
|
||||
bool _isGpuTexture = false;
|
||||
bool get isGpuTexture => _isGpuTexture;
|
||||
|
||||
setTextureType({bool gpuTexture = false}) {
|
||||
_isGpuTexture = gpuTexture;
|
||||
textureID.value = _isGpuTexture ? gpuTextureId : rgbaTextureId;
|
||||
}
|
||||
|
||||
setRgbaTextureId(int id) {
|
||||
_rgbaTextureId = id;
|
||||
textureID.value = _isGpuTexture ? gpuTextureId : rgbaTextureId;
|
||||
}
|
||||
|
||||
setGpuTextureId(int id) {
|
||||
_gpuTextureId = id;
|
||||
textureID.value = _isGpuTexture ? gpuTextureId : rgbaTextureId;
|
||||
}
|
||||
}
|
||||
|
||||
class TextureModel {
|
||||
final WeakReference<FFI> parent;
|
||||
final Map<int, _Control> _control = {};
|
||||
final Map<int, _PixelbufferTexture> _pixelbufferRenderTextures = {};
|
||||
final Map<int, _GpuTexture> _gpuRenderTextures = {};
|
||||
|
||||
TextureModel(this.parent);
|
||||
|
||||
setTextureType({required int display, required bool gpuTexture}) {
|
||||
debugPrint("setTextureType: display=$display, isGpuTexture=$gpuTexture");
|
||||
ensureControl(display);
|
||||
_control[display]?.setTextureType(gpuTexture: gpuTexture);
|
||||
// For versions that do not support multiple displays, the display parameter is always 0, need set type of current display
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
if (!ffi.ffiModel.pi.isSupportMultiDisplay) {
|
||||
final currentDisplay = CurrentDisplayState.find(ffi.id).value;
|
||||
if (currentDisplay != display) {
|
||||
debugPrint(
|
||||
"setTextureType: currentDisplay=$currentDisplay, isGpuTexture=$gpuTexture");
|
||||
ensureControl(currentDisplay);
|
||||
_control[currentDisplay]?.setTextureType(gpuTexture: gpuTexture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRgbaTextureId({required int display, required int id}) {
|
||||
ensureControl(display);
|
||||
_control[display]?.setRgbaTextureId(id);
|
||||
}
|
||||
|
||||
setGpuTextureId({required int display, required int id}) {
|
||||
ensureControl(display);
|
||||
_control[display]?.setGpuTextureId(id);
|
||||
}
|
||||
|
||||
RxInt getTextureId(int display) {
|
||||
ensureControl(display);
|
||||
return _control[display]!.textureID;
|
||||
}
|
||||
|
||||
updateCurrentDisplay(int curDisplay) {
|
||||
if (isWeb) return;
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
tryCreateTexture(int idx) {
|
||||
if (!_pixelbufferRenderTextures.containsKey(idx)) {
|
||||
final renderTexture = _PixelbufferTexture();
|
||||
_pixelbufferRenderTextures[idx] = renderTexture;
|
||||
renderTexture.create(idx, ffi.sessionId, ffi);
|
||||
}
|
||||
if (!_gpuRenderTextures.containsKey(idx)) {
|
||||
final renderTexture = _GpuTexture();
|
||||
_gpuRenderTextures[idx] = renderTexture;
|
||||
renderTexture.create(idx, ffi.sessionId, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
tryRemoveTexture(int idx) {
|
||||
_control.remove(idx);
|
||||
if (_pixelbufferRenderTextures.containsKey(idx)) {
|
||||
_pixelbufferRenderTextures[idx]!.destroy(true, ffi);
|
||||
_pixelbufferRenderTextures.remove(idx);
|
||||
}
|
||||
if (_gpuRenderTextures.containsKey(idx)) {
|
||||
_gpuRenderTextures[idx]!.destroy(true, ffi);
|
||||
_gpuRenderTextures.remove(idx);
|
||||
}
|
||||
}
|
||||
|
||||
if (curDisplay == kAllDisplayValue) {
|
||||
final displays = ffi.ffiModel.pi.getCurDisplays();
|
||||
for (var i = 0; i < displays.length; i++) {
|
||||
tryCreateTexture(i);
|
||||
}
|
||||
} else {
|
||||
tryCreateTexture(curDisplay);
|
||||
for (var i = 0; i < ffi.ffiModel.pi.displays.length; i++) {
|
||||
if (i != curDisplay) {
|
||||
tryRemoveTexture(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onRemotePageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
onViewCameraPageDispose(bool closeSession) async {
|
||||
final ffi = parent.target;
|
||||
if (ffi == null) return;
|
||||
for (final texture in _pixelbufferRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
for (final texture in _gpuRenderTextures.values) {
|
||||
await texture.destroy(closeSession, ffi);
|
||||
}
|
||||
}
|
||||
|
||||
ensureControl(int display) {
|
||||
var ctl = _control[display];
|
||||
if (ctl == null) {
|
||||
ctl = _Control();
|
||||
_control[display] = ctl;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,378 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/common/widgets/peers_view.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'dart:convert';
|
||||
import '../utils/http_service.dart' as http;
|
||||
|
||||
class GroupModel {
|
||||
final RxBool groupLoading = false.obs;
|
||||
final RxString groupLoadError = "".obs;
|
||||
final RxList<DeviceGroupPayload> deviceGroups = RxList.empty(growable: true);
|
||||
final RxList<UserPayload> users = RxList.empty(growable: true);
|
||||
final RxList<Peer> peers = RxList.empty(growable: true);
|
||||
final RxBool isSelectedDeviceGroup = false.obs;
|
||||
final RxString selectedAccessibleItemName = ''.obs;
|
||||
final RxString searchAccessibleItemNameText = ''.obs;
|
||||
WeakReference<FFI> parent;
|
||||
var initialized = false;
|
||||
var _cacheLoadOnceFlag = false;
|
||||
var _statusCode = 200;
|
||||
|
||||
final Map<String, VoidCallback> _peerIdUpdateListeners = {};
|
||||
|
||||
bool get emtpy => deviceGroups.isEmpty && users.isEmpty && peers.isEmpty;
|
||||
|
||||
late final Peers peersModel;
|
||||
|
||||
GroupModel(this.parent) {
|
||||
peersModel = Peers(
|
||||
name: PeersModelName.group,
|
||||
getInitPeers: () => peers,
|
||||
loadEvent: LoadEvent.group);
|
||||
}
|
||||
|
||||
Future<void> pull({force = true, quiet = false}) async {
|
||||
if (bind.isDisableGroupPanel()) return;
|
||||
if (!gFFI.userModel.isLogin || groupLoading.value) return;
|
||||
if (gFFI.userModel.networkError.isNotEmpty) return;
|
||||
if (!force && initialized) return;
|
||||
if (!quiet) {
|
||||
groupLoading.value = true;
|
||||
groupLoadError.value = "";
|
||||
}
|
||||
try {
|
||||
await _pull();
|
||||
_tryHandlePullError();
|
||||
} catch (e) {
|
||||
print("pull accessibles error: $e");
|
||||
}
|
||||
groupLoading.value = false;
|
||||
initialized = true;
|
||||
platformFFI.tryHandle({'name': LoadEvent.group});
|
||||
if (_statusCode == 401) {
|
||||
gFFI.userModel.reset(resetOther: true);
|
||||
} else {
|
||||
_saveCache();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pull() async {
|
||||
List<DeviceGroupPayload> tmpDeviceGroups = List.empty(growable: true);
|
||||
if (!await _getDeviceGroups(tmpDeviceGroups)) {
|
||||
// old hbbs doesn't support this api
|
||||
// return;
|
||||
}
|
||||
tmpDeviceGroups.sort((a, b) => a.name.compareTo(b.name));
|
||||
List<UserPayload> tmpUsers = List.empty(growable: true);
|
||||
if (!await _getUsers(tmpUsers)) {
|
||||
return;
|
||||
}
|
||||
List<Peer> tmpPeers = List.empty(growable: true);
|
||||
if (!await _getPeers(tmpPeers)) {
|
||||
return;
|
||||
}
|
||||
deviceGroups.value = tmpDeviceGroups;
|
||||
// me first
|
||||
var index = tmpUsers
|
||||
.indexWhere((user) => user.name == gFFI.userModel.userName.value);
|
||||
if (index != -1) {
|
||||
var user = tmpUsers.removeAt(index);
|
||||
tmpUsers.insert(0, user);
|
||||
}
|
||||
users.value = tmpUsers;
|
||||
if (!users.any((u) => u.name == selectedAccessibleItemName.value) &&
|
||||
!deviceGroups.any((d) => d.name == selectedAccessibleItemName.value)) {
|
||||
selectedAccessibleItemName.value = '';
|
||||
}
|
||||
// recover online
|
||||
final oldOnlineIDs = peers.where((e) => e.online).map((e) => e.id).toList();
|
||||
peers.value = tmpPeers;
|
||||
peers
|
||||
.where((e) => oldOnlineIDs.contains(e.id))
|
||||
.map((e) => e.online = true)
|
||||
.toList();
|
||||
groupLoadError.value = '';
|
||||
_callbackPeerUpdate();
|
||||
}
|
||||
|
||||
Future<bool> _getDeviceGroups(
|
||||
List<DeviceGroupPayload> tmpDeviceGroups) async {
|
||||
final api = "${await bind.mainGetApiServer()}/api/device-group/accessible";
|
||||
try {
|
||||
var uri0 = Uri.parse(api);
|
||||
final pageSize = 100;
|
||||
var total = 0;
|
||||
int current = 0;
|
||||
do {
|
||||
current += 1;
|
||||
var uri = Uri(
|
||||
scheme: uri0.scheme,
|
||||
host: uri0.host,
|
||||
path: uri0.path,
|
||||
port: uri0.port,
|
||||
queryParameters: {
|
||||
'current': current.toString(),
|
||||
'pageSize': pageSize.toString(),
|
||||
});
|
||||
final resp = await http.get(uri, headers: getHttpHeaders());
|
||||
_statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
}
|
||||
if (json.containsKey('total')) {
|
||||
if (total == 0) total = json['total'];
|
||||
if (json.containsKey('data')) {
|
||||
final data = json['data'];
|
||||
if (data is List) {
|
||||
for (final user in data) {
|
||||
final u = DeviceGroupPayload.fromJson(user);
|
||||
int index = tmpDeviceGroups.indexWhere((e) => e.name == u.name);
|
||||
if (index < 0) {
|
||||
tmpDeviceGroups.add(u);
|
||||
} else {
|
||||
tmpDeviceGroups[index] = u;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (current * pageSize < total);
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('get accessible device groups: $err');
|
||||
// old hbbs doesn't support this api
|
||||
// groupLoadError.value =
|
||||
// '${translate('pull_group_failed_tip')}: ${translate(err.toString())}';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _getUsers(List<UserPayload> tmpUsers) async {
|
||||
final api = "${await bind.mainGetApiServer()}/api/users";
|
||||
try {
|
||||
var uri0 = Uri.parse(api);
|
||||
final pageSize = 100;
|
||||
var total = 0;
|
||||
int current = 0;
|
||||
do {
|
||||
current += 1;
|
||||
var uri = Uri(
|
||||
scheme: uri0.scheme,
|
||||
host: uri0.host,
|
||||
path: uri0.path,
|
||||
port: uri0.port,
|
||||
queryParameters: {
|
||||
'current': current.toString(),
|
||||
'pageSize': pageSize.toString(),
|
||||
'accessible': '',
|
||||
'status': '1',
|
||||
});
|
||||
final resp = await http.get(uri, headers: getHttpHeaders());
|
||||
_statusCode = resp.statusCode;
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
if (json['error'] == 'Admin required!' ||
|
||||
json['error']
|
||||
.toString()
|
||||
.contains('ambiguous column name: status')) {
|
||||
throw translate('upgrade_rustdesk_server_pro_to_{1.1.10}_tip');
|
||||
} else {
|
||||
throw json['error'];
|
||||
}
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
}
|
||||
if (json.containsKey('total')) {
|
||||
if (total == 0) total = json['total'];
|
||||
if (json.containsKey('data')) {
|
||||
final data = json['data'];
|
||||
if (data is List) {
|
||||
for (final user in data) {
|
||||
final u = UserPayload.fromJson(user);
|
||||
int index = tmpUsers.indexWhere((e) => e.name == u.name);
|
||||
if (index < 0) {
|
||||
tmpUsers.add(u);
|
||||
} else {
|
||||
tmpUsers[index] = u;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (current * pageSize < total);
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('get accessible users: $err');
|
||||
groupLoadError.value =
|
||||
'${translate('pull_group_failed_tip')}: ${translate(err.toString())}';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _getPeers(List<Peer> tmpPeers) async {
|
||||
try {
|
||||
final api = "${await bind.mainGetApiServer()}/api/peers";
|
||||
var uri0 = Uri.parse(api);
|
||||
final pageSize = 100;
|
||||
var total = 0;
|
||||
int current = 0;
|
||||
do {
|
||||
current += 1;
|
||||
var queryParameters = {
|
||||
'current': current.toString(),
|
||||
'pageSize': pageSize.toString(),
|
||||
'accessible': '',
|
||||
'status': '1',
|
||||
};
|
||||
var uri = Uri(
|
||||
scheme: uri0.scheme,
|
||||
host: uri0.host,
|
||||
path: uri0.path,
|
||||
port: uri0.port,
|
||||
queryParameters: queryParameters);
|
||||
final resp = await http.get(uri, headers: getHttpHeaders());
|
||||
_statusCode = resp.statusCode;
|
||||
|
||||
Map<String, dynamic> json =
|
||||
_jsonDecodeResp(decode_http_response(resp), resp.statusCode);
|
||||
if (json.containsKey('error')) {
|
||||
throw json['error'];
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw 'HTTP ${resp.statusCode}';
|
||||
}
|
||||
if (json.containsKey('total')) {
|
||||
if (total == 0) total = json['total'];
|
||||
if (json.containsKey('data')) {
|
||||
final data = json['data'];
|
||||
if (data is List) {
|
||||
for (final p in data) {
|
||||
final peerPayload = PeerPayload.fromJson(p);
|
||||
final peer = PeerPayload.toPeer(peerPayload);
|
||||
int index = tmpPeers.indexWhere((e) => e.id == peer.id);
|
||||
if (index < 0) {
|
||||
tmpPeers.add(peer);
|
||||
} else {
|
||||
tmpPeers[index] = peer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (current * pageSize < total);
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugPrint('get accessible peers: $err');
|
||||
groupLoadError.value =
|
||||
'${translate('pull_group_failed_tip')}: ${translate(err.toString())}';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _jsonDecodeResp(String body, int statusCode) {
|
||||
try {
|
||||
Map<String, dynamic> json = jsonDecode(body);
|
||||
return json;
|
||||
} catch (e) {
|
||||
final err = body.isNotEmpty && body.length < 128 ? body : e.toString();
|
||||
if (statusCode != 200) {
|
||||
throw 'HTTP $statusCode, $err';
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
void _saveCache() {
|
||||
try {
|
||||
final map = (<String, dynamic>{
|
||||
"access_token": bind.mainGetLocalOption(key: 'access_token'),
|
||||
"device_groups": deviceGroups.map((e) => e.toGroupCacheJson()).toList(),
|
||||
"users": users.map((e) => e.toGroupCacheJson()).toList(),
|
||||
'peers': peers.map((e) => e.toGroupCacheJson()).toList()
|
||||
});
|
||||
bind.mainSaveGroup(json: jsonEncode(map));
|
||||
} catch (e) {
|
||||
debugPrint('group save:$e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadCache() async {
|
||||
try {
|
||||
if (_cacheLoadOnceFlag || groupLoading.value || initialized) return;
|
||||
_cacheLoadOnceFlag = true;
|
||||
final access_token = bind.mainGetLocalOption(key: 'access_token');
|
||||
if (access_token.isEmpty) return;
|
||||
final cache = await bind.mainLoadGroup();
|
||||
if (groupLoading.value) return;
|
||||
final data = jsonDecode(cache);
|
||||
if (data == null || data['access_token'] != access_token) return;
|
||||
deviceGroups.clear();
|
||||
users.clear();
|
||||
peers.clear();
|
||||
if (data['device_groups'] is List) {
|
||||
for (var u in data['device_groups']) {
|
||||
deviceGroups.add(DeviceGroupPayload.fromJson(u));
|
||||
}
|
||||
}
|
||||
if (data['users'] is List) {
|
||||
for (var u in data['users']) {
|
||||
users.add(UserPayload.fromJson(u));
|
||||
}
|
||||
}
|
||||
if (data['peers'] is List) {
|
||||
for (final peer in data['peers']) {
|
||||
peers.add(Peer.fromJson(peer));
|
||||
}
|
||||
_callbackPeerUpdate();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("load group cache: $e");
|
||||
}
|
||||
}
|
||||
|
||||
reset() async {
|
||||
initialized = false;
|
||||
groupLoadError.value = '';
|
||||
deviceGroups.clear();
|
||||
users.clear();
|
||||
peers.clear();
|
||||
selectedAccessibleItemName.value = '';
|
||||
await bind.mainClearGroup();
|
||||
}
|
||||
|
||||
void _callbackPeerUpdate() {
|
||||
for (var listener in _peerIdUpdateListeners.values) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
void addPeerUpdateListener(String key, VoidCallback listener) {
|
||||
_peerIdUpdateListeners[key] = listener;
|
||||
}
|
||||
|
||||
void removePeerUpdateListener(String key) {
|
||||
_peerIdUpdateListeners.remove(key);
|
||||
}
|
||||
|
||||
void _tryHandlePullError() {
|
||||
String errorMessage = groupLoadError.value;
|
||||
// The error message is "Retrieving accessible devices is disabled."
|
||||
if (errorMessage.toLowerCase().contains('disabled')) {
|
||||
users.clear();
|
||||
peers.clear();
|
||||
deviceGroups.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Returns true when a stale mobile one-shot Shift state should be released
|
||||
/// by replaying a tracked Shift key-down as a synthesized key-up.
|
||||
///
|
||||
/// This is only valid on mobile when Flutter's cached Shift state is still on
|
||||
/// (`cachedShiftPressed == true`) but the current hardware/raw event reports
|
||||
/// Shift as off (`actualShiftPressed == false`).
|
||||
///
|
||||
/// A tracked Shift key-down is required so the caller can safely synthesize the
|
||||
/// matching key-up. Both `shiftLeft` and `shiftRight` are excluded because the
|
||||
/// Shift key event itself must be processed first; otherwise we could release
|
||||
/// the tracked key while still handling the original Shift press/release.
|
||||
/// Callers should evaluate this only after their cached modifier state has been
|
||||
/// updated for the current event.
|
||||
///
|
||||
/// When this returns true, the caller logs a line like:
|
||||
/// `input: releasing stale mobile Shift before replaying tracked raw key-up`
|
||||
/// immediately before calling `_releaseTrackedRawShiftKeyEventIfNeeded()`.
|
||||
bool shouldReleaseStaleMobileShift({
|
||||
required bool isMobile,
|
||||
required bool cachedShiftPressed,
|
||||
required bool actualShiftPressed,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool hasTrackedShiftKeyDown,
|
||||
}) {
|
||||
if (!isMobile || !cachedShiftPressed || actualShiftPressed) {
|
||||
return false;
|
||||
}
|
||||
if (!hasTrackedShiftKeyDown) {
|
||||
return false;
|
||||
}
|
||||
if (logicalKey == LogicalKeyboardKey.shiftLeft ||
|
||||
logicalKey == LogicalKeyboardKey.shiftRight) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../generated_bridge.dart';
|
||||
|
||||
final class RgbaFrame extends Struct {
|
||||
@Uint32()
|
||||
external int len;
|
||||
external Pointer<Uint8> data;
|
||||
}
|
||||
|
||||
typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
|
||||
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
/// FFI wrapper around the native Rust core.
|
||||
/// Hides the platform differences.
|
||||
class PlatformFFI {
|
||||
String _dir = '';
|
||||
// _homeDir is only needed for Android and IOS.
|
||||
String _homeDir = '';
|
||||
final _eventHandlers = <String, Map<String, HandleEvent>>{};
|
||||
late RustdeskImpl _ffiBind;
|
||||
late String _appType;
|
||||
StreamEventHandler? _eventCallback;
|
||||
|
||||
PlatformFFI._();
|
||||
|
||||
static final PlatformFFI instance = PlatformFFI._();
|
||||
final _toAndroidChannel = const MethodChannel('mChannel');
|
||||
|
||||
RustdeskImpl get ffiBind => _ffiBind;
|
||||
F3? _session_get_rgba;
|
||||
|
||||
static get localeName => Platform.localeName;
|
||||
|
||||
static get isMain => instance._appType == kAppTypeMain;
|
||||
|
||||
static String getByName(String name, [String arg = '']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
static void setByName(String name, [String value = '']) {}
|
||||
|
||||
static Future<String> getVersion() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
return packageInfo.version;
|
||||
}
|
||||
|
||||
bool registerEventHandler(
|
||||
String eventName, String handlerName, HandleEvent handler, {bool replace = false}) {
|
||||
debugPrint('registerEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers == null) {
|
||||
_eventHandlers[eventName] = {handlerName: handler};
|
||||
return true;
|
||||
} else {
|
||||
if (!replace && handlers.containsKey(handlerName)) {
|
||||
return false;
|
||||
} else {
|
||||
handlers[handlerName] = handler;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void unregisterEventHandler(String eventName, String handlerName) {
|
||||
debugPrint('unregisterEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers != null) {
|
||||
handlers.remove(handlerName);
|
||||
}
|
||||
}
|
||||
|
||||
String translate(String name, String locale) =>
|
||||
_ffiBind.translate(name: name, locale: locale);
|
||||
|
||||
Uint8List? getRgba(SessionID sessionId, int display, int bufSize) {
|
||||
if (_session_get_rgba == null) return null;
|
||||
final sessionIdStr = sessionId.toString();
|
||||
var a = sessionIdStr.toNativeUtf8();
|
||||
try {
|
||||
final buffer = _session_get_rgba!(a, display);
|
||||
if (buffer == nullptr) {
|
||||
return null;
|
||||
}
|
||||
final data = buffer.asTypedList(bufSize);
|
||||
return data;
|
||||
} finally {
|
||||
malloc.free(a);
|
||||
}
|
||||
}
|
||||
|
||||
int getRgbaSize(SessionID sessionId, int display) =>
|
||||
_ffiBind.sessionGetRgbaSize(sessionId: sessionId, display: display);
|
||||
void nextRgba(SessionID sessionId, int display) =>
|
||||
_ffiBind.sessionNextRgba(sessionId: sessionId, display: display);
|
||||
void registerPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterPixelbufferTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
|
||||
/// Init the FFI class, loads the native Rust core library.
|
||||
Future<void> init(String appType) async {
|
||||
_appType = appType;
|
||||
final dylib = isAndroid
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
: isLinux
|
||||
? DynamicLibrary.open('librustdesk.so')
|
||||
: isWindows
|
||||
? DynamicLibrary.open('librustdesk.dll')
|
||||
:
|
||||
// Use executable itself as the dynamic library for MacOS.
|
||||
// Multiple dylib instances will cause some global instances to be invalid.
|
||||
// eg. `lazy_static` objects in rust side, will be created more than once, which is not expected.
|
||||
//
|
||||
// isMacOS? DynamicLibrary.open("liblibrustdesk.dylib") :
|
||||
DynamicLibrary.process();
|
||||
debugPrint('initializing FFI $_appType');
|
||||
try {
|
||||
_session_get_rgba = dylib.lookupFunction<F3Dart, F3>("session_get_rgba");
|
||||
try {
|
||||
// SYSTEM user failed
|
||||
_dir = (await getApplicationDocumentsDirectory()).path;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get documents directory: $e');
|
||||
}
|
||||
_ffiBind = RustdeskImpl(dylib);
|
||||
|
||||
if (isLinux) {
|
||||
if (isMain) {
|
||||
// Start a dbus service for uri links, no need to await
|
||||
_ffiBind.mainStartDbusServer();
|
||||
}
|
||||
} else if (isMacOS && isMain) {
|
||||
// Start ipc service for uri links.
|
||||
_ffiBind.mainStartIpcUrlServer();
|
||||
}
|
||||
_startListenEvent(_ffiBind); // global event
|
||||
try {
|
||||
if (isAndroid) {
|
||||
// only support for android
|
||||
_homeDir = (await ExternalPath.getExternalStorageDirectories())[0];
|
||||
} else if (isIOS) {
|
||||
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
||||
// which provided the `downloads` path in the sandbox.
|
||||
// It is unclear why we now use the `data` directory in the sandbox instead.
|
||||
_homeDir = _ffiBind.mainGetDataDirIos(appDir: _dir);
|
||||
} else {
|
||||
// no need to set home dir
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrintStack(label: 'initialize failed: $e');
|
||||
}
|
||||
String id = 'NA';
|
||||
String name = 'Flutter';
|
||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||
if (isAndroid) {
|
||||
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
||||
name = '${androidInfo.brand}-${androidInfo.model}';
|
||||
id = androidInfo.id.hashCode.toString();
|
||||
androidVersion = androidInfo.version.sdkInt;
|
||||
} else if (isIOS) {
|
||||
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
|
||||
name = iosInfo.utsname.machine;
|
||||
id = iosInfo.identifierForVendor.hashCode.toString();
|
||||
} else if (isLinux) {
|
||||
LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo;
|
||||
name = linuxInfo.name;
|
||||
id = linuxInfo.machineId ?? linuxInfo.id;
|
||||
} else if (isWindows) {
|
||||
try {
|
||||
// request windows build number to fix overflow on win7
|
||||
windowsBuildNumber = getWindowsTargetBuildNumber();
|
||||
WindowsDeviceInfo winInfo = await deviceInfo.windowsInfo;
|
||||
name = winInfo.computerName;
|
||||
id = winInfo.computerName;
|
||||
} catch (e) {
|
||||
debugPrintStack(label: "get windows device info failed: $e");
|
||||
name = "unknown";
|
||||
id = "unknown";
|
||||
}
|
||||
} else if (isMacOS) {
|
||||
MacOsDeviceInfo macOsInfo = await deviceInfo.macOsInfo;
|
||||
name = macOsInfo.computerName;
|
||||
id = macOsInfo.systemGUID ?? '';
|
||||
}
|
||||
if (isAndroid || isIOS) {
|
||||
debugPrint(
|
||||
'_appType:$_appType,info1-id:$id,info2-name:$name,dir:$_dir,homeDir:$_homeDir');
|
||||
} else {
|
||||
debugPrint(
|
||||
'_appType:$_appType,info1-id:$id,info2-name:$name,dir:$_dir');
|
||||
}
|
||||
if (desktopType == DesktopType.cm) {
|
||||
await _ffiBind.cmInit();
|
||||
}
|
||||
await _ffiBind.mainDeviceId(id: id);
|
||||
await _ffiBind.mainDeviceName(name: name);
|
||||
await _ffiBind.mainSetHomeDir(home: _homeDir);
|
||||
await _ffiBind.mainInit(
|
||||
appDir: _dir,
|
||||
customClientConfig: '',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrintStack(label: 'initialize failed: $e');
|
||||
}
|
||||
version = await getVersion();
|
||||
}
|
||||
|
||||
Future<bool> tryHandle(Map<String, dynamic> evt) async {
|
||||
final name = evt['name'];
|
||||
if (name != null) {
|
||||
final handlers = _eventHandlers[name];
|
||||
if (handlers != null) {
|
||||
if (handlers.isNotEmpty) {
|
||||
for (var handler in handlers.values) {
|
||||
await handler(evt);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Start listening to the Rust core's events and frames.
|
||||
void _startListenEvent(RustdeskImpl rustdeskImpl) {
|
||||
final appType =
|
||||
_appType == kAppTypeDesktopRemote ? '$_appType,$kWindowId' : _appType;
|
||||
var sink = rustdeskImpl.startGlobalEventStream(appType: appType);
|
||||
sink.listen((message) {
|
||||
() async {
|
||||
try {
|
||||
Map<String, dynamic> event = json.decode(message);
|
||||
// _tryHandle here may be more flexible than _eventCallback
|
||||
if (!await tryHandle(event)) {
|
||||
if (_eventCallback != null) {
|
||||
await _eventCallback!(event);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('json.decode fail(): $e');
|
||||
}
|
||||
}();
|
||||
});
|
||||
}
|
||||
|
||||
void setEventCallback(StreamEventHandler fun) async {
|
||||
_eventCallback = fun;
|
||||
}
|
||||
|
||||
void setRgbaCallback(void Function(int, Uint8List) fun) async {}
|
||||
|
||||
void startDesktopWebListener() {}
|
||||
|
||||
void stopDesktopWebListener() {}
|
||||
|
||||
void setMethodCallHandler(FMethod callback) {
|
||||
_toAndroidChannel.setMethodCallHandler((call) async {
|
||||
callback(call.method, call.arguments);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
invokeMethod(String method, [dynamic arguments]) async {
|
||||
if (!isAndroid) return Future<bool>(() => false);
|
||||
return await _toAndroidChannel.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
void syncAndroidServiceAppDirConfigPath() {
|
||||
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
|
||||
}
|
||||
|
||||
void setFullscreenCallback(void Function(bool) fun) {}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'platform_model.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
class Peer {
|
||||
final String id;
|
||||
String hash; // personal ab hash password
|
||||
String password; // shared ab password
|
||||
String username; // pc username
|
||||
String hostname;
|
||||
String platform;
|
||||
String alias;
|
||||
List<dynamic> tags;
|
||||
bool forceAlwaysRelay = false;
|
||||
String rdpPort;
|
||||
String rdpUsername;
|
||||
bool online = false;
|
||||
String loginName; //login username
|
||||
String device_group_name;
|
||||
String note;
|
||||
bool? sameServer;
|
||||
|
||||
String getId() {
|
||||
if (alias != '') {
|
||||
return alias;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
Peer.fromJson(Map<String, dynamic> json)
|
||||
: id = json['id'] ?? '',
|
||||
hash = json['hash'] ?? '',
|
||||
password = json['password'] ?? '',
|
||||
username = json['username'] ?? '',
|
||||
hostname = json['hostname'] ?? '',
|
||||
platform = json['platform'] ?? '',
|
||||
alias = json['alias'] ?? '',
|
||||
tags = json['tags'] ?? [],
|
||||
forceAlwaysRelay = json['forceAlwaysRelay'] == 'true',
|
||||
rdpPort = json['rdpPort'] ?? '',
|
||||
rdpUsername = json['rdpUsername'] ?? '',
|
||||
loginName = json['loginName'] ?? '',
|
||||
device_group_name = json['device_group_name'] ?? '',
|
||||
note = json['note'] is String ? json['note'] : '',
|
||||
sameServer = json['same_server'];
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"password": password,
|
||||
"username": username,
|
||||
"hostname": hostname,
|
||||
"platform": platform,
|
||||
"alias": alias,
|
||||
"tags": tags,
|
||||
"forceAlwaysRelay": forceAlwaysRelay.toString(),
|
||||
"rdpPort": rdpPort,
|
||||
"rdpUsername": rdpUsername,
|
||||
'loginName': loginName,
|
||||
'device_group_name': device_group_name,
|
||||
'note': note,
|
||||
'same_server': sameServer,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toCustomJson({required bool includingHash}) {
|
||||
var res = <String, dynamic>{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"hostname": hostname,
|
||||
"platform": platform,
|
||||
"alias": alias,
|
||||
"tags": tags,
|
||||
};
|
||||
if (includingHash) {
|
||||
res['hash'] = hash;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toGroupCacheJson() {
|
||||
return <String, dynamic>{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"hostname": hostname,
|
||||
"platform": platform,
|
||||
"login_name": loginName,
|
||||
"device_group_name": device_group_name,
|
||||
};
|
||||
}
|
||||
|
||||
Peer({
|
||||
required this.id,
|
||||
required this.hash,
|
||||
required this.password,
|
||||
required this.username,
|
||||
required this.hostname,
|
||||
required this.platform,
|
||||
required this.alias,
|
||||
required this.tags,
|
||||
required this.forceAlwaysRelay,
|
||||
required this.rdpPort,
|
||||
required this.rdpUsername,
|
||||
required this.loginName,
|
||||
required this.device_group_name,
|
||||
required this.note,
|
||||
this.sameServer,
|
||||
});
|
||||
|
||||
Peer.loading()
|
||||
: this(
|
||||
id: '...',
|
||||
hash: '',
|
||||
password: '',
|
||||
username: '...',
|
||||
hostname: '...',
|
||||
platform: '...',
|
||||
alias: '',
|
||||
tags: [],
|
||||
forceAlwaysRelay: false,
|
||||
rdpPort: '',
|
||||
rdpUsername: '',
|
||||
loginName: '',
|
||||
device_group_name: '',
|
||||
note: '',
|
||||
);
|
||||
bool equal(Peer other) {
|
||||
return id == other.id &&
|
||||
hash == other.hash &&
|
||||
password == other.password &&
|
||||
username == other.username &&
|
||||
hostname == other.hostname &&
|
||||
platform == other.platform &&
|
||||
alias == other.alias &&
|
||||
tags.equals(other.tags) &&
|
||||
forceAlwaysRelay == other.forceAlwaysRelay &&
|
||||
rdpPort == other.rdpPort &&
|
||||
rdpUsername == other.rdpUsername &&
|
||||
device_group_name == other.device_group_name &&
|
||||
loginName == other.loginName &&
|
||||
note == other.note;
|
||||
}
|
||||
|
||||
Peer.copy(Peer other)
|
||||
: this(
|
||||
id: other.id,
|
||||
hash: other.hash,
|
||||
password: other.password,
|
||||
username: other.username,
|
||||
hostname: other.hostname,
|
||||
platform: other.platform,
|
||||
alias: other.alias,
|
||||
tags: other.tags.toList(),
|
||||
forceAlwaysRelay: other.forceAlwaysRelay,
|
||||
rdpPort: other.rdpPort,
|
||||
rdpUsername: other.rdpUsername,
|
||||
loginName: other.loginName,
|
||||
device_group_name: other.device_group_name,
|
||||
note: other.note,
|
||||
sameServer: other.sameServer);
|
||||
}
|
||||
|
||||
enum UpdateEvent { online, load }
|
||||
|
||||
typedef GetInitPeers = RxList<Peer> Function();
|
||||
|
||||
class Peers extends ChangeNotifier {
|
||||
final String name;
|
||||
final String loadEvent;
|
||||
List<Peer> peers = List.empty(growable: true);
|
||||
// Part of the peers that are not in the rest peers list.
|
||||
// When there're too many peers, we may want to load the front 100 peers first,
|
||||
// so we can see peers in UI quickly. `restPeerIds` is the rest peers' ids.
|
||||
// And then load all peers later.
|
||||
List<String> restPeerIds = List.empty(growable: true);
|
||||
final GetInitPeers? getInitPeers;
|
||||
UpdateEvent event = UpdateEvent.load;
|
||||
static const _cbQueryOnlines = 'callback_query_onlines';
|
||||
|
||||
Peers(
|
||||
{required this.name,
|
||||
required this.getInitPeers,
|
||||
required this.loadEvent}) {
|
||||
peers = getInitPeers?.call() ?? [];
|
||||
platformFFI.registerEventHandler(_cbQueryOnlines, name, (evt) async {
|
||||
_updateOnlineState(evt);
|
||||
});
|
||||
platformFFI.registerEventHandler(loadEvent, name, (evt) async {
|
||||
_updatePeers(evt);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
platformFFI.unregisterEventHandler(_cbQueryOnlines, name);
|
||||
platformFFI.unregisterEventHandler(loadEvent, name);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Peer getByIndex(int index) {
|
||||
if (index < peers.length) {
|
||||
return peers[index];
|
||||
} else {
|
||||
return Peer.loading();
|
||||
}
|
||||
}
|
||||
|
||||
int getPeersCount() {
|
||||
return peers.length;
|
||||
}
|
||||
|
||||
void _updateOnlineState(Map<String, dynamic> evt) {
|
||||
int changedCount = 0;
|
||||
evt['onlines'].split(',').forEach((online) {
|
||||
for (var i = 0; i < peers.length; i++) {
|
||||
if (peers[i].id == online) {
|
||||
if (!peers[i].online) {
|
||||
changedCount += 1;
|
||||
peers[i].online = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
evt['offlines'].split(',').forEach((offline) {
|
||||
for (var i = 0; i < peers.length; i++) {
|
||||
if (peers[i].id == offline) {
|
||||
if (peers[i].online) {
|
||||
changedCount += 1;
|
||||
peers[i].online = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changedCount > 0) {
|
||||
event = UpdateEvent.online;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _updatePeers(Map<String, dynamic> evt) {
|
||||
final onlineStates = _getOnlineStates();
|
||||
if (getInitPeers != null) {
|
||||
peers = getInitPeers?.call() ?? [];
|
||||
} else {
|
||||
peers = _decodePeers(evt['peers']);
|
||||
}
|
||||
|
||||
restPeerIds = [];
|
||||
if (evt['ids'] != null) {
|
||||
restPeerIds = (evt['ids'] as String).split(',');
|
||||
}
|
||||
|
||||
for (var peer in peers) {
|
||||
final state = onlineStates[peer.id];
|
||||
peer.online = state != null && state != false;
|
||||
}
|
||||
event = UpdateEvent.load;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Map<String, bool> _getOnlineStates() {
|
||||
var onlineStates = <String, bool>{};
|
||||
for (var peer in peers) {
|
||||
onlineStates[peer.id] = peer.online;
|
||||
}
|
||||
return onlineStates;
|
||||
}
|
||||
|
||||
List<Peer> _decodePeers(String peersStr) {
|
||||
try {
|
||||
if (peersStr == "") return [];
|
||||
List<dynamic> peers = json.decode(peersStr);
|
||||
return peers.map((peer) {
|
||||
return Peer.fromJson(peer as Map<String, dynamic>);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
debugPrint('peers(): $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/peer_model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import 'model.dart';
|
||||
|
||||
enum PeerTabIndex {
|
||||
recent,
|
||||
fav,
|
||||
lan,
|
||||
ab,
|
||||
group,
|
||||
}
|
||||
|
||||
class PeerTabModel with ChangeNotifier {
|
||||
WeakReference<FFI> parent;
|
||||
int get currentTab => _currentTab;
|
||||
int _currentTab = 0; // index in tabNames
|
||||
static const int maxTabCount = 5;
|
||||
static const List<String> tabNames = [
|
||||
'Recent sessions',
|
||||
'Favorites',
|
||||
'Discovered',
|
||||
'Address book',
|
||||
'Accessible devices',
|
||||
];
|
||||
static const List<IconData> icons = [
|
||||
Icons.access_time_filled,
|
||||
Icons.star,
|
||||
Icons.explore,
|
||||
IconFont.addressBook,
|
||||
IconFont.deviceGroupFill,
|
||||
];
|
||||
List<bool> isEnabled = List.from([
|
||||
true,
|
||||
true,
|
||||
!isWeb && bind.mainGetLocalOption(key: "disable-discovery-panel") != "Y",
|
||||
!(bind.isDisableAb() || bind.isDisableAccount()),
|
||||
!(bind.isDisableGroupPanel() || bind.isDisableAccount()),
|
||||
]);
|
||||
final List<bool> _isVisible = List.filled(maxTabCount, true, growable: false);
|
||||
List<bool> get isVisibleEnabled => () {
|
||||
final list = _isVisible.toList();
|
||||
for (int i = 0; i < maxTabCount; i++) {
|
||||
list[i] = list[i] && isEnabled[i];
|
||||
}
|
||||
return list;
|
||||
}();
|
||||
final List<int> orders =
|
||||
List.generate(maxTabCount, (index) => index, growable: false);
|
||||
List<int> get visibleEnabledOrderedIndexs =>
|
||||
orders.where((e) => isVisibleEnabled[e]).toList();
|
||||
List<Peer> _selectedPeers = List.empty(growable: true);
|
||||
List<Peer> get selectedPeers => _selectedPeers;
|
||||
bool _multiSelectionMode = false;
|
||||
bool get multiSelectionMode => _multiSelectionMode;
|
||||
List<Peer> _currentTabCachedPeers = List.empty(growable: true);
|
||||
List<Peer> get currentTabCachedPeers => _currentTabCachedPeers;
|
||||
bool _isShiftDown = false;
|
||||
bool get isShiftDown => _isShiftDown;
|
||||
String _lastId = '';
|
||||
String get lastId => _lastId;
|
||||
|
||||
PeerTabModel(this.parent) {
|
||||
// visible
|
||||
try {
|
||||
final option = bind.getLocalFlutterOption(k: kOptionPeerTabVisible);
|
||||
if (option.isNotEmpty) {
|
||||
List<dynamic> decodeList = jsonDecode(option);
|
||||
if (decodeList.length == _isVisible.length) {
|
||||
for (int i = 0; i < _isVisible.length; i++) {
|
||||
if (decodeList[i] is bool) {
|
||||
_isVisible[i] = decodeList[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("failed to get peer tab visible list:$e");
|
||||
}
|
||||
// order
|
||||
try {
|
||||
final option = bind.getLocalFlutterOption(k: kOptionPeerTabOrder);
|
||||
if (option.isNotEmpty) {
|
||||
List<dynamic> decodeList = jsonDecode(option);
|
||||
if (decodeList.length == maxTabCount) {
|
||||
var sortedList = decodeList.toList();
|
||||
sortedList.sort();
|
||||
bool valid = true;
|
||||
for (int i = 0; i < maxTabCount; i++) {
|
||||
if (sortedList[i] is! int || sortedList[i] != i) {
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
for (int i = 0; i < orders.length; i++) {
|
||||
orders[i] = decodeList[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("failed to get peer tab order list: $e");
|
||||
}
|
||||
// init currentTab
|
||||
_currentTab =
|
||||
int.tryParse(bind.getLocalFlutterOption(k: kOptionPeerTabIndex)) ?? 0;
|
||||
if (_currentTab < 0 || _currentTab >= maxTabCount) {
|
||||
_currentTab = 0;
|
||||
}
|
||||
_trySetCurrentTabToFirstVisibleEnabled();
|
||||
}
|
||||
|
||||
setCurrentTab(int index) {
|
||||
if (_currentTab != index) {
|
||||
_currentTab = index;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String tabTooltip(int index) {
|
||||
if (index >= 0 && index < tabNames.length) {
|
||||
return translate(tabNames[index]);
|
||||
}
|
||||
return index.toString();
|
||||
}
|
||||
|
||||
IconData tabIcon(int index) {
|
||||
if (index >= 0 && index < icons.length) {
|
||||
return icons[index];
|
||||
}
|
||||
return Icons.help;
|
||||
}
|
||||
|
||||
setMultiSelectionMode(bool mode) {
|
||||
_multiSelectionMode = mode;
|
||||
if (!mode) {
|
||||
_selectedPeers.clear();
|
||||
_lastId = '';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
select(Peer peer) {
|
||||
if (!_multiSelectionMode) {
|
||||
// https://github.com/flutter/flutter/issues/101275#issuecomment-1604541700
|
||||
// After onTap, the shift key should be pressed for a while when not in multiselection mode,
|
||||
// because onTap is delayed when onDoubleTap is not null
|
||||
if (isDesktop || isWebDesktop) return;
|
||||
_multiSelectionMode = true;
|
||||
}
|
||||
final cached = _currentTabCachedPeers.map((e) => e.id).toList();
|
||||
int thisIndex = cached.indexOf(peer.id);
|
||||
int lastIndex = cached.indexOf(_lastId);
|
||||
if (_isShiftDown && thisIndex >= 0 && lastIndex >= 0) {
|
||||
int start = min(thisIndex, lastIndex);
|
||||
int end = max(thisIndex, lastIndex);
|
||||
bool remove = isPeerSelected(peer.id);
|
||||
for (var i = start; i <= end; i++) {
|
||||
if (remove) {
|
||||
if (isPeerSelected(cached[i])) {
|
||||
_selectedPeers.removeWhere((p) => p.id == cached[i]);
|
||||
}
|
||||
} else {
|
||||
if (!isPeerSelected(cached[i])) {
|
||||
_selectedPeers.add(_currentTabCachedPeers[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isPeerSelected(peer.id)) {
|
||||
_selectedPeers.removeWhere((p) => p.id == peer.id);
|
||||
} else {
|
||||
_selectedPeers.add(peer);
|
||||
}
|
||||
}
|
||||
_lastId = peer.id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// `notifyListeners()` will cause many rebuilds.
|
||||
// So, we need to reduce the calls to "notifyListeners()" only when necessary.
|
||||
// A better way is to use a new model.
|
||||
setCurrentTabCachedPeers(List<Peer> peers) {
|
||||
Future.delayed(Duration.zero, () {
|
||||
final isPreEmpty = _currentTabCachedPeers.isEmpty;
|
||||
_currentTabCachedPeers = peers;
|
||||
final isNowEmpty = _currentTabCachedPeers.isEmpty;
|
||||
if (isPreEmpty != isNowEmpty) {
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
_selectedPeers = _currentTabCachedPeers.toList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isPeerSelected(String id) {
|
||||
return selectedPeers.firstWhereOrNull((p) => p.id == id) != null;
|
||||
}
|
||||
|
||||
setShiftDown(bool v) {
|
||||
if (_isShiftDown != v) {
|
||||
_isShiftDown = v;
|
||||
if (_multiSelectionMode) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTabVisible(int index, bool visible) {
|
||||
if (index >= 0 && index < maxTabCount) {
|
||||
if (_isVisible[index] != visible) {
|
||||
_isVisible[index] = visible;
|
||||
if (index == _currentTab && !visible) {
|
||||
_trySetCurrentTabToFirstVisibleEnabled();
|
||||
} else if (visible && visibleEnabledOrderedIndexs.length == 1) {
|
||||
_currentTab = index;
|
||||
}
|
||||
try {
|
||||
bind.setLocalFlutterOption(
|
||||
k: kOptionPeerTabVisible, v: jsonEncode(_isVisible));
|
||||
} catch (_) {}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_trySetCurrentTabToFirstVisibleEnabled() {
|
||||
if (!visibleEnabledOrderedIndexs.contains(_currentTab)) {
|
||||
if (visibleEnabledOrderedIndexs.isNotEmpty) {
|
||||
_currentTab = visibleEnabledOrderedIndexs.first;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reorder(int oldIndex, int newIndex) {
|
||||
if (oldIndex < newIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
if (oldIndex < 0 || oldIndex >= visibleEnabledOrderedIndexs.length) {
|
||||
return;
|
||||
}
|
||||
if (newIndex < 0 || newIndex >= visibleEnabledOrderedIndexs.length) {
|
||||
return;
|
||||
}
|
||||
final oldTabValue = visibleEnabledOrderedIndexs[oldIndex];
|
||||
final newTabValue = visibleEnabledOrderedIndexs[newIndex];
|
||||
int oldValueIndex = orders.indexOf(oldTabValue);
|
||||
int newValueIndex = orders.indexOf(newTabValue);
|
||||
final list = orders.toList();
|
||||
if (oldIndex != -1 && newIndex != -1) {
|
||||
list.removeAt(oldValueIndex);
|
||||
list.insert(newValueIndex, oldTabValue);
|
||||
for (int i = 0; i < list.length; i++) {
|
||||
orders[i] = list[i];
|
||||
}
|
||||
bind.setLocalFlutterOption(k: kOptionPeerTabOrder, v: jsonEncode(orders));
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'native_model.dart' if (dart.library.html) 'web_model.dart';
|
||||
import 'package:flutter_hbb/generated_bridge.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/bridge.dart';
|
||||
|
||||
final platformFFI = PlatformFFI.instance;
|
||||
final localeName = PlatformFFI.localeName;
|
||||
|
||||
RustdeskImpl get bind => platformFFI.ffiBind;
|
||||
|
||||
String ffiGetByName(String name, [String arg = '']) {
|
||||
return PlatformFFI.getByName(name, arg);
|
||||
}
|
||||
|
||||
void ffiSetByName(String name, [String value = '']) {
|
||||
PlatformFFI.setByName(name, value);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
class PrinterOptions {
|
||||
String action;
|
||||
List<String> printerNames;
|
||||
String printerName;
|
||||
|
||||
PrinterOptions(
|
||||
{required this.action,
|
||||
required this.printerNames,
|
||||
required this.printerName});
|
||||
|
||||
static PrinterOptions load() {
|
||||
var action = bind.mainGetLocalOption(key: kKeyPrinterIncomingJobAction);
|
||||
if (![
|
||||
kValuePrinterIncomingJobDismiss,
|
||||
kValuePrinterIncomingJobDefault,
|
||||
kValuePrinterIncomingJobSelected
|
||||
].contains(action)) {
|
||||
action = kValuePrinterIncomingJobDefault;
|
||||
}
|
||||
|
||||
final printerNames = getPrinterNames();
|
||||
var selectedPrinterName = bind.mainGetLocalOption(key: kKeyPrinterSelected);
|
||||
if (!printerNames.contains(selectedPrinterName)) {
|
||||
if (action == kValuePrinterIncomingJobSelected) {
|
||||
action = kValuePrinterIncomingJobDefault;
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterIncomingJobAction,
|
||||
value: kValuePrinterIncomingJobDefault);
|
||||
if (printerNames.isEmpty) {
|
||||
selectedPrinterName = '';
|
||||
} else {
|
||||
selectedPrinterName = printerNames.first;
|
||||
}
|
||||
bind.mainSetLocalOption(
|
||||
key: kKeyPrinterSelected, value: selectedPrinterName);
|
||||
}
|
||||
}
|
||||
|
||||
return PrinterOptions(
|
||||
action: action,
|
||||
printerNames: printerNames,
|
||||
printerName: selectedPrinterName);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/mobile/pages/settings_page.dart';
|
||||
import 'package:flutter_hbb/models/chat_model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../common/formatter/id_formatter.dart';
|
||||
import '../desktop/pages/server_page.dart' as desktop;
|
||||
import '../desktop/widgets/tabbar_widget.dart';
|
||||
import '../mobile/pages/server_page.dart';
|
||||
import 'model.dart';
|
||||
|
||||
const kLoginDialogTag = "LOGIN";
|
||||
|
||||
const kUseTemporaryPassword = "use-temporary-password";
|
||||
const kUsePermanentPassword = "use-permanent-password";
|
||||
const kUseBothPasswords = "use-both-passwords";
|
||||
|
||||
class ServerModel with ChangeNotifier {
|
||||
bool _isStart = false; // Android MainService status
|
||||
bool _mediaOk = false;
|
||||
bool _inputOk = false;
|
||||
bool _audioOk = false;
|
||||
bool _fileOk = false;
|
||||
bool _clipboardOk = false;
|
||||
bool _showElevation = false;
|
||||
bool hideCm = false;
|
||||
int _connectStatus = 0; // Rendezvous Server status
|
||||
String _verificationMethod = "";
|
||||
String _temporaryPasswordLength = "";
|
||||
bool _allowNumericOneTimePassword = false;
|
||||
String _approveMode = "";
|
||||
int _zeroClientLengthCounter = 0;
|
||||
|
||||
late String _emptyIdShow;
|
||||
late final IDTextEditingController _serverId;
|
||||
final _serverPasswd =
|
||||
TextEditingController(text: translate("Generating ..."));
|
||||
|
||||
final tabController = DesktopTabController(tabType: DesktopTabType.cm);
|
||||
|
||||
final List<Client> _clients = [];
|
||||
|
||||
Timer? cmHiddenTimer;
|
||||
|
||||
final _wakelockKey = UniqueKey();
|
||||
|
||||
bool get isStart => _isStart;
|
||||
|
||||
bool get mediaOk => _mediaOk;
|
||||
|
||||
bool get inputOk => _inputOk;
|
||||
|
||||
bool get audioOk => _audioOk;
|
||||
|
||||
bool get fileOk => _fileOk;
|
||||
|
||||
bool get clipboardOk => _clipboardOk;
|
||||
|
||||
bool get showElevation => _showElevation;
|
||||
|
||||
int get connectStatus => _connectStatus;
|
||||
|
||||
String get verificationMethod {
|
||||
final index = [
|
||||
kUseTemporaryPassword,
|
||||
kUsePermanentPassword,
|
||||
kUseBothPasswords
|
||||
].indexOf(_verificationMethod);
|
||||
if (index < 0) {
|
||||
return kUseBothPasswords;
|
||||
}
|
||||
return _verificationMethod;
|
||||
}
|
||||
|
||||
String get approveMode => _approveMode;
|
||||
|
||||
setVerificationMethod(String method) async {
|
||||
await bind.mainSetOption(key: kOptionVerificationMethod, value: method);
|
||||
/*
|
||||
if (method != kUsePermanentPassword) {
|
||||
await bind.mainSetOption(
|
||||
key: 'allow-hide-cm', value: bool2option('allow-hide-cm', false));
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
String get temporaryPasswordLength {
|
||||
final lengthIndex = ["6", "8", "10"].indexOf(_temporaryPasswordLength);
|
||||
if (lengthIndex < 0) {
|
||||
return "6";
|
||||
}
|
||||
return _temporaryPasswordLength;
|
||||
}
|
||||
|
||||
setTemporaryPasswordLength(String length) async {
|
||||
await bind.mainSetOption(key: "temporary-password-length", value: length);
|
||||
}
|
||||
|
||||
setApproveMode(String mode) async {
|
||||
await bind.mainSetOption(key: kOptionApproveMode, value: mode);
|
||||
/*
|
||||
if (mode != 'password') {
|
||||
await bind.mainSetOption(
|
||||
key: 'allow-hide-cm', value: bool2option('allow-hide-cm', false));
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
bool get allowNumericOneTimePassword => _allowNumericOneTimePassword;
|
||||
switchAllowNumericOneTimePassword() async {
|
||||
await mainSetBoolOption(
|
||||
kOptionAllowNumericOneTimePassword, !_allowNumericOneTimePassword);
|
||||
}
|
||||
|
||||
TextEditingController get serverId => _serverId;
|
||||
|
||||
TextEditingController get serverPasswd => _serverPasswd;
|
||||
|
||||
List<Client> get clients => _clients;
|
||||
|
||||
final controller = ScrollController();
|
||||
|
||||
WeakReference<FFI> parent;
|
||||
|
||||
ServerModel(this.parent) {
|
||||
_emptyIdShow = translate("Generating ...");
|
||||
_serverId = IDTextEditingController(text: _emptyIdShow);
|
||||
|
||||
/*
|
||||
// initital _hideCm at startup
|
||||
final verificationMethod =
|
||||
bind.mainGetOptionSync(key: kOptionVerificationMethod);
|
||||
final approveMode = bind.mainGetOptionSync(key: kOptionApproveMode);
|
||||
_hideCm = option2bool(
|
||||
'allow-hide-cm', bind.mainGetOptionSync(key: 'allow-hide-cm'));
|
||||
if (!(approveMode == 'password' &&
|
||||
verificationMethod == kUsePermanentPassword)) {
|
||||
_hideCm = false;
|
||||
}
|
||||
*/
|
||||
|
||||
timerCallback() async {
|
||||
final connectionStatus =
|
||||
jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>;
|
||||
final statusNum = connectionStatus['status_num'] as int;
|
||||
if (statusNum != _connectStatus) {
|
||||
_connectStatus = statusNum;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (desktopType == DesktopType.cm) {
|
||||
final res = await bind.cmCheckClientsLength(length: _clients.length);
|
||||
if (res != null) {
|
||||
debugPrint("clients not match!");
|
||||
updateClientState(res);
|
||||
} else {
|
||||
if (_clients.isEmpty) {
|
||||
hideCmWindow();
|
||||
if (_zeroClientLengthCounter++ == 12) {
|
||||
// 6 second
|
||||
windowManager.close();
|
||||
}
|
||||
} else {
|
||||
_zeroClientLengthCounter = 0;
|
||||
if (!hideCm) showCmWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePasswordModel();
|
||||
}
|
||||
|
||||
if (!isTest) {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
if (await bind.optionSynced()) {
|
||||
await timerCallback();
|
||||
}
|
||||
});
|
||||
Timer.periodic(Duration(milliseconds: 500), (timer) async {
|
||||
await timerCallback();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial keyboard status is off on mobile
|
||||
if (isMobile) {
|
||||
bind.mainSetOption(key: kOptionEnableKeyboard, value: 'N');
|
||||
}
|
||||
}
|
||||
|
||||
/// 1. check android permission
|
||||
/// 2. check config
|
||||
/// audio true by default (if permission on) (false default < Android 10)
|
||||
/// file true by default (if permission on)
|
||||
checkAndroidPermission() async {
|
||||
// audio
|
||||
if (androidVersion < 30 ||
|
||||
!await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
_audioOk = false;
|
||||
bind.mainSetOption(key: kOptionEnableAudio, value: "N");
|
||||
} else {
|
||||
final audioOption = await bind.mainGetOption(key: kOptionEnableAudio);
|
||||
_audioOk = audioOption != 'N';
|
||||
}
|
||||
|
||||
// file
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
_fileOk = false;
|
||||
bind.mainSetOption(key: kOptionEnableFileTransfer, value: "N");
|
||||
} else {
|
||||
final fileOption =
|
||||
await bind.mainGetOption(key: kOptionEnableFileTransfer);
|
||||
_fileOk = fileOption != 'N';
|
||||
}
|
||||
|
||||
// clipboard
|
||||
final clipOption = await bind.mainGetOption(key: kOptionEnableClipboard);
|
||||
_clipboardOk = clipOption != 'N';
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
updatePasswordModel() async {
|
||||
var update = false;
|
||||
final temporaryPassword = await bind.mainGetTemporaryPassword();
|
||||
final verificationMethod =
|
||||
await bind.mainGetOption(key: kOptionVerificationMethod);
|
||||
final temporaryPasswordLength =
|
||||
await bind.mainGetOption(key: "temporary-password-length");
|
||||
final approveMode = await bind.mainGetOption(key: kOptionApproveMode);
|
||||
final numericOneTimePassword =
|
||||
await mainGetBoolOption(kOptionAllowNumericOneTimePassword);
|
||||
/*
|
||||
var hideCm = option2bool(
|
||||
'allow-hide-cm', await bind.mainGetOption(key: 'allow-hide-cm'));
|
||||
if (!(approveMode == 'password' &&
|
||||
verificationMethod == kUsePermanentPassword)) {
|
||||
hideCm = false;
|
||||
}
|
||||
*/
|
||||
if (_approveMode != approveMode) {
|
||||
_approveMode = approveMode;
|
||||
update = true;
|
||||
}
|
||||
var stopped = await mainGetBoolOption(kOptionStopService);
|
||||
final oldPwdText = _serverPasswd.text;
|
||||
if (stopped ||
|
||||
verificationMethod == kUsePermanentPassword ||
|
||||
_approveMode == 'click') {
|
||||
_serverPasswd.text = '-';
|
||||
} else {
|
||||
if (_serverPasswd.text != temporaryPassword &&
|
||||
temporaryPassword.isNotEmpty) {
|
||||
_serverPasswd.text = temporaryPassword;
|
||||
}
|
||||
}
|
||||
if (oldPwdText != _serverPasswd.text) {
|
||||
update = true;
|
||||
}
|
||||
if (_verificationMethod != verificationMethod) {
|
||||
_verificationMethod = verificationMethod;
|
||||
update = true;
|
||||
}
|
||||
if (_temporaryPasswordLength != temporaryPasswordLength) {
|
||||
if (_temporaryPasswordLength.isNotEmpty) {
|
||||
bind.mainUpdateTemporaryPassword();
|
||||
}
|
||||
_temporaryPasswordLength = temporaryPasswordLength;
|
||||
update = true;
|
||||
}
|
||||
if (_allowNumericOneTimePassword != numericOneTimePassword) {
|
||||
_allowNumericOneTimePassword = numericOneTimePassword;
|
||||
update = true;
|
||||
}
|
||||
/*
|
||||
if (_hideCm != hideCm) {
|
||||
_hideCm = hideCm;
|
||||
if (desktopType == DesktopType.cm) {
|
||||
if (hideCm) {
|
||||
await hideCmWindow();
|
||||
} else {
|
||||
await showCmWindow();
|
||||
}
|
||||
}
|
||||
update = true;
|
||||
}
|
||||
*/
|
||||
if (update) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
toggleAudio() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_audioOk && !await AndroidPermissionManager.check(kRecordAudio)) {
|
||||
final res = await AndroidPermissionManager.request(kRecordAudio);
|
||||
if (!res) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_audioOk = !_audioOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableAudio, value: _audioOk ? defaultOptionYes : 'N');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
toggleFile() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (!_fileOk &&
|
||||
!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
final res =
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
if (!res) {
|
||||
showToast(translate('Failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_fileOk = !_fileOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableFileTransfer,
|
||||
value: _fileOk ? defaultOptionYes : 'N');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
toggleClipboard() async {
|
||||
_clipboardOk = !clipboardOk;
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableClipboard,
|
||||
value: clipboardOk ? defaultOptionYes : 'N');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
toggleInput() async {
|
||||
if (clients.any((c) => !c.disconnected)) {
|
||||
await showClientsMayNotBeChangedAlert(parent.target);
|
||||
}
|
||||
if (_inputOk) {
|
||||
parent.target?.invokeMethod("stop_input");
|
||||
bind.mainSetOption(key: kOptionEnableKeyboard, value: 'N');
|
||||
} else {
|
||||
if (parent.target != null) {
|
||||
/// the result of toggle-on depends on user actions in the settings page.
|
||||
/// handle result, see [ServerModel.changeStatue]
|
||||
showInputWarnAlert(parent.target!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> checkRequestNotificationPermission() async {
|
||||
debugPrint("androidVersion $androidVersion");
|
||||
if (androidVersion < 33) {
|
||||
return true;
|
||||
}
|
||||
if (await AndroidPermissionManager.check(kAndroid13Notification)) {
|
||||
debugPrint("notification permission already granted");
|
||||
return true;
|
||||
}
|
||||
var res = await AndroidPermissionManager.request(kAndroid13Notification);
|
||||
debugPrint("notification permission request result: $res");
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<bool> checkFloatingWindowPermission() async {
|
||||
debugPrint("androidVersion $androidVersion");
|
||||
if (androidVersion < 23) {
|
||||
return false;
|
||||
}
|
||||
if (await AndroidPermissionManager.check(kSystemAlertWindow)) {
|
||||
debugPrint("alert window permission already granted");
|
||||
return true;
|
||||
}
|
||||
var res = await AndroidPermissionManager.request(kSystemAlertWindow);
|
||||
debugPrint("alert window permission request result: $res");
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Toggle the screen sharing service.
|
||||
toggleService() async {
|
||||
if (_isStart) {
|
||||
final res = await parent.target?.dialogManager
|
||||
.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
return CustomAlertDialog(
|
||||
title: Row(children: [
|
||||
const Icon(Icons.warning_amber_sharp,
|
||||
color: Colors.redAccent, size: 28),
|
||||
const SizedBox(width: 10),
|
||||
Text(translate("Warning")),
|
||||
]),
|
||||
content: Text(translate("android_stop_service_tip")),
|
||||
actions: [
|
||||
TextButton(onPressed: close, child: Text(translate("Cancel"))),
|
||||
TextButton(onPressed: submit, child: Text(translate("OK"))),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
if (res == true) {
|
||||
stopService();
|
||||
}
|
||||
} else {
|
||||
await checkRequestNotificationPermission();
|
||||
if (bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) != 'Y') {
|
||||
await checkFloatingWindowPermission();
|
||||
}
|
||||
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
||||
await AndroidPermissionManager.request(kManageExternalStorage);
|
||||
}
|
||||
final res = await parent.target?.dialogManager
|
||||
.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
return CustomAlertDialog(
|
||||
title: Row(children: [
|
||||
const Icon(Icons.warning_amber_sharp,
|
||||
color: Colors.redAccent, size: 28),
|
||||
const SizedBox(width: 10),
|
||||
Text(translate("Warning")),
|
||||
]),
|
||||
content: Text(translate("android_service_will_start_tip")),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
if (res == true) {
|
||||
startService();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the screen sharing service.
|
||||
Future<void> startService() async {
|
||||
_isStart = true;
|
||||
notifyListeners();
|
||||
parent.target?.ffiModel.updateEventListener(parent.target!.sessionId, "");
|
||||
await parent.target?.invokeMethod("init_service");
|
||||
// ugly is here, because for desktop, below is useless
|
||||
await bind.mainStartService();
|
||||
updateClientState();
|
||||
if (isAndroid) {
|
||||
androidUpdatekeepScreenOn();
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the screen sharing service.
|
||||
Future<void> stopService() async {
|
||||
_isStart = false;
|
||||
closeAll();
|
||||
await parent.target?.invokeMethod("stop_service");
|
||||
await bind.mainStopService();
|
||||
notifyListeners();
|
||||
// for androidUpdatekeepScreenOn only
|
||||
WakelockManager.disable(_wakelockKey);
|
||||
}
|
||||
|
||||
fetchID() async {
|
||||
final id = await bind.mainGetMyId();
|
||||
if (id != _serverId.id) {
|
||||
_serverId.id = id;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
changeStatue(String name, bool value) {
|
||||
debugPrint("changeStatue value $value");
|
||||
switch (name) {
|
||||
case "media":
|
||||
_mediaOk = value;
|
||||
if (value && !_isStart) {
|
||||
startService();
|
||||
}
|
||||
break;
|
||||
case "input":
|
||||
if (_inputOk != value) {
|
||||
bind.mainSetOption(
|
||||
key: kOptionEnableKeyboard,
|
||||
value: value ? defaultOptionYes : 'N');
|
||||
}
|
||||
_inputOk = value;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// force
|
||||
updateClientState([String? json]) async {
|
||||
if (isTest) return;
|
||||
var res = await bind.cmGetClientsState();
|
||||
List<dynamic> clientsJson;
|
||||
try {
|
||||
clientsJson = jsonDecode(res);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to decode clientsJson: '$res', error $e");
|
||||
return;
|
||||
}
|
||||
|
||||
final oldClientLenght = _clients.length;
|
||||
_clients.clear();
|
||||
tabController.state.value.tabs.clear();
|
||||
|
||||
for (var clientJson in clientsJson) {
|
||||
try {
|
||||
final client = Client.fromJson(clientJson);
|
||||
_clients.add(client);
|
||||
_addTab(client);
|
||||
} catch (e) {
|
||||
debugPrint("Failed to decode clientJson '$clientJson', error $e");
|
||||
}
|
||||
}
|
||||
if (desktopType == DesktopType.cm) {
|
||||
if (_clients.isEmpty) {
|
||||
hideCmWindow();
|
||||
} else if (!hideCm) {
|
||||
showCmWindow();
|
||||
}
|
||||
}
|
||||
if (_clients.length != oldClientLenght) {
|
||||
notifyListeners();
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
}
|
||||
}
|
||||
|
||||
void addConnection(Map<String, dynamic> evt) {
|
||||
try {
|
||||
final client = Client.fromJson(jsonDecode(evt["client"]));
|
||||
if (client.authorized) {
|
||||
parent.target?.dialogManager.dismissByTag(getLoginDialogTag(client.id));
|
||||
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||
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 {
|
||||
final index = _clients.indexWhere((c) => c.id == client.id);
|
||||
if (index >= 0) {
|
||||
_clients[index].privacyMode = client.privacyMode;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
_clients.add(client);
|
||||
}
|
||||
_addTab(client);
|
||||
// remove disconnected
|
||||
final index_disconnected = _clients
|
||||
.indexWhere((c) => c.disconnected && c.peerId == client.peerId);
|
||||
if (index_disconnected >= 0) {
|
||||
_clients.removeAt(index_disconnected);
|
||||
tabController.remove(index_disconnected);
|
||||
}
|
||||
if (desktopType == DesktopType.cm && !hideCm) {
|
||||
showCmWindow();
|
||||
}
|
||||
scrollToBottom();
|
||||
notifyListeners();
|
||||
if (isAndroid && !client.authorized) showLoginDialog(client);
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
} catch (e) {
|
||||
debugPrint("Failed to call loginRequest,error:$e");
|
||||
}
|
||||
}
|
||||
|
||||
void _addTab(Client client) {
|
||||
tabController.add(TabInfo(
|
||||
key: client.id.toString(),
|
||||
label: client.name,
|
||||
closable: false,
|
||||
onTap: () {},
|
||||
page: desktop.buildConnectionCard(client)));
|
||||
Future.delayed(Duration.zero, () async {
|
||||
if (!hideCm) windowOnTop(null);
|
||||
});
|
||||
// Only do the hidden task when on Desktop.
|
||||
if (client.authorized && isDesktop) {
|
||||
cmHiddenTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (!hideCm) windowManager.minimize();
|
||||
cmHiddenTimer = null;
|
||||
});
|
||||
}
|
||||
parent.target?.chatModel
|
||||
.updateConnIdOfKey(MessageKey(client.peerId, client.id));
|
||||
}
|
||||
|
||||
void showLoginDialog(Client client) {
|
||||
showClientDialog(
|
||||
client,
|
||||
client.isFileTransfer
|
||||
? "Transfer file"
|
||||
: client.isViewCamera
|
||||
? "View camera"
|
||||
: client.isTerminal
|
||||
? "Terminal"
|
||||
: "Share screen",
|
||||
'Do you accept?',
|
||||
'android_new_connection_tip',
|
||||
() => sendLoginResponse(client, false),
|
||||
() => sendLoginResponse(client, true),
|
||||
);
|
||||
}
|
||||
|
||||
handleVoiceCall(Client client, bool accept) {
|
||||
parent.target?.invokeMethod("cancel_notification", client.id);
|
||||
bind.cmHandleIncomingVoiceCall(id: client.id, accept: accept);
|
||||
}
|
||||
|
||||
showVoiceCallDialog(Client client) {
|
||||
showClientDialog(
|
||||
client,
|
||||
'Voice call',
|
||||
'Do you accept?',
|
||||
'android_new_voice_call_tip',
|
||||
() => handleVoiceCall(client, false),
|
||||
() => handleVoiceCall(client, true),
|
||||
);
|
||||
}
|
||||
|
||||
showClientDialog(Client client, String title, String contentTitle,
|
||||
String content, VoidCallback onCancel, VoidCallback onSubmit) {
|
||||
parent.target?.dialogManager.show((setState, close, context) {
|
||||
cancel() {
|
||||
onCancel();
|
||||
close();
|
||||
}
|
||||
|
||||
submit() {
|
||||
onSubmit();
|
||||
close();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title:
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||
Text(translate(title)),
|
||||
IconButton(onPressed: close, icon: const Icon(Icons.close))
|
||||
]),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(translate(contentTitle)),
|
||||
ClientInfo(client),
|
||||
Text(
|
||||
translate(content),
|
||||
style: Theme.of(globalKey.currentContext!).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Dismiss", onPressed: cancel, isOutline: true),
|
||||
if (approveMode != 'password')
|
||||
dialogButton("Accept", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: cancel,
|
||||
);
|
||||
}, tag: getLoginDialogTag(client.id));
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
if (isDesktop) return;
|
||||
Future.delayed(Duration(milliseconds: 200), () {
|
||||
controller.animateTo(controller.position.maxScrollExtent,
|
||||
duration: Duration(milliseconds: 200),
|
||||
curve: Curves.fastLinearToSlowEaseIn);
|
||||
});
|
||||
}
|
||||
|
||||
void sendLoginResponse(Client client, bool res) async {
|
||||
if (res) {
|
||||
bind.cmLoginRes(connId: client.id, res: res);
|
||||
if (!client.isFileTransfer && !client.isTerminal) {
|
||||
parent.target?.invokeMethod("start_capture");
|
||||
}
|
||||
parent.target?.invokeMethod("cancel_notification", client.id);
|
||||
client.authorized = true;
|
||||
notifyListeners();
|
||||
} else {
|
||||
bind.cmLoginRes(connId: client.id, res: res);
|
||||
parent.target?.invokeMethod("cancel_notification", client.id);
|
||||
final index = _clients.indexOf(client);
|
||||
tabController.remove(index);
|
||||
_clients.remove(client);
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
}
|
||||
}
|
||||
|
||||
void onClientRemove(Map<String, dynamic> evt) {
|
||||
try {
|
||||
final id = int.parse(evt['id'] as String);
|
||||
final close = (evt['close'] as String) == 'true';
|
||||
if (_clients.any((c) => c.id == id)) {
|
||||
final index = _clients.indexWhere((client) => client.id == id);
|
||||
if (index >= 0) {
|
||||
if (close) {
|
||||
_clients.removeAt(index);
|
||||
tabController.remove(index);
|
||||
} else {
|
||||
_clients[index].disconnected = true;
|
||||
}
|
||||
}
|
||||
parent.target?.dialogManager.dismissByTag(getLoginDialogTag(id));
|
||||
parent.target?.invokeMethod("cancel_notification", id);
|
||||
}
|
||||
if (desktopType == DesktopType.cm && _clients.isEmpty) {
|
||||
hideCmWindow();
|
||||
}
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint("onClientRemove failed,error:$e");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> closeAll() async {
|
||||
await Future.wait(
|
||||
_clients.map((client) => bind.cmCloseConnection(connId: client.id)));
|
||||
_clients.clear();
|
||||
tabController.state.value.tabs.clear();
|
||||
if (isAndroid) androidUpdatekeepScreenOn();
|
||||
}
|
||||
|
||||
void jumpTo(int id) {
|
||||
final index = _clients.indexWhere((client) => client.id == id);
|
||||
tabController.jumpTo(index);
|
||||
}
|
||||
|
||||
void setShowElevation(bool show) {
|
||||
if (_showElevation != show) {
|
||||
_showElevation = show;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void updateVoiceCallState(Map<String, dynamic> evt) {
|
||||
try {
|
||||
final client = Client.fromJson(jsonDecode(evt["client"]));
|
||||
final index = _clients.indexWhere((element) => element.id == client.id);
|
||||
if (index != -1) {
|
||||
_clients[index].inVoiceCall = client.inVoiceCall;
|
||||
_clients[index].incomingVoiceCall = client.incomingVoiceCall;
|
||||
if (client.incomingVoiceCall) {
|
||||
if (isAndroid) {
|
||||
showVoiceCallDialog(client);
|
||||
} else {
|
||||
// Has incoming phone call, let's set the window on top.
|
||||
Future.delayed(Duration.zero, () {
|
||||
windowOnTop(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("updateVoiceCallState failed: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void androidUpdatekeepScreenOn() async {
|
||||
if (!isAndroid) return;
|
||||
var floatingWindowDisabled =
|
||||
bind.mainGetLocalOption(key: kOptionDisableFloatingWindow) == "Y" ||
|
||||
!await AndroidPermissionManager.check(kSystemAlertWindow);
|
||||
final keepScreenOn = floatingWindowDisabled
|
||||
? KeepScreenOn.never
|
||||
: optionToKeepScreenOn(
|
||||
bind.mainGetLocalOption(key: kOptionKeepScreenOn));
|
||||
final on = ((keepScreenOn == KeepScreenOn.serviceOn) && _isStart) ||
|
||||
(keepScreenOn == KeepScreenOn.duringControlled &&
|
||||
_clients.map((e) => !e.disconnected).isNotEmpty);
|
||||
if (on) {
|
||||
WakelockManager.enable(_wakelockKey, isServer: true);
|
||||
} else {
|
||||
WakelockManager.disable(_wakelockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClientType {
|
||||
remote,
|
||||
file,
|
||||
camera,
|
||||
portForward,
|
||||
terminal,
|
||||
}
|
||||
|
||||
class Client {
|
||||
int id = 0; // client connections inner count id
|
||||
bool authorized = false;
|
||||
bool isFileTransfer = false;
|
||||
bool isViewCamera = false;
|
||||
bool isTerminal = false;
|
||||
String portForward = "";
|
||||
String name = "";
|
||||
String avatar = "";
|
||||
String peerId = ""; // peer user's id,show at app
|
||||
bool keyboard = false;
|
||||
bool clipboard = false;
|
||||
bool audio = false;
|
||||
bool file = false;
|
||||
bool restart = false;
|
||||
bool recording = false;
|
||||
bool blockInput = false;
|
||||
bool privacyMode = false;
|
||||
bool disconnected = false;
|
||||
bool fromSwitch = false;
|
||||
bool inVoiceCall = false;
|
||||
bool incomingVoiceCall = false;
|
||||
|
||||
RxInt unreadChatMessageCount = 0.obs;
|
||||
|
||||
Client(this.id, this.authorized, this.isFileTransfer, this.isViewCamera,
|
||||
this.name, this.peerId, this.keyboard, this.clipboard, this.audio);
|
||||
|
||||
Client.fromJson(Map<String, dynamic> json) {
|
||||
id = json['id'];
|
||||
authorized = json['authorized'];
|
||||
isFileTransfer = json['is_file_transfer'];
|
||||
// TODO: no entry then default.
|
||||
isViewCamera = json['is_view_camera'];
|
||||
isTerminal = json['is_terminal'] ?? false;
|
||||
portForward = json['port_forward'];
|
||||
name = json['name'];
|
||||
avatar = json['avatar'] ?? '';
|
||||
peerId = json['peer_id'];
|
||||
keyboard = json['keyboard'];
|
||||
clipboard = json['clipboard'];
|
||||
audio = json['audio'];
|
||||
file = json['file'];
|
||||
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'];
|
||||
incomingVoiceCall = json['incoming_voice_call'];
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final Map<String, dynamic> data = <String, dynamic>{};
|
||||
data['id'] = id;
|
||||
data['authorized'] = authorized;
|
||||
data['is_file_transfer'] = isFileTransfer;
|
||||
data['is_view_camera'] = isViewCamera;
|
||||
data['is_terminal'] = isTerminal;
|
||||
data['port_forward'] = portForward;
|
||||
data['name'] = name;
|
||||
data['avatar'] = avatar;
|
||||
data['peer_id'] = peerId;
|
||||
data['keyboard'] = keyboard;
|
||||
data['clipboard'] = clipboard;
|
||||
data['audio'] = audio;
|
||||
data['file'] = file;
|
||||
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;
|
||||
data['incoming_voice_call'] = incomingVoiceCall;
|
||||
return data;
|
||||
}
|
||||
|
||||
ClientType type_() {
|
||||
if (isFileTransfer) {
|
||||
return ClientType.file;
|
||||
} else if (isViewCamera) {
|
||||
return ClientType.camera;
|
||||
} else if (isTerminal) {
|
||||
return ClientType.terminal;
|
||||
} else if (portForward.isNotEmpty) {
|
||||
return ClientType.portForward;
|
||||
} else {
|
||||
return ClientType.remote;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String getLoginDialogTag(int id) {
|
||||
return kLoginDialogTag + id.toString();
|
||||
}
|
||||
|
||||
showInputWarnAlert(FFI ffi) {
|
||||
ffi.dialogManager.show((setState, close, context) {
|
||||
submit() {
|
||||
AndroidPermissionManager.startAction(kActionAccessibilitySettings);
|
||||
close();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("How to get Android input permission?")),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(translate("android_input_permission_tip1")),
|
||||
const SizedBox(height: 10),
|
||||
Text(translate("android_input_permission_tip2")),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("Open System Setting", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> showClientsMayNotBeChangedAlert(FFI? ffi) async {
|
||||
await ffi?.dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Permissions")),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(translate("android_permission_may_not_change_tip")),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("OK", onPressed: close),
|
||||
],
|
||||
onSubmit: close,
|
||||
onCancel: close,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../consts.dart';
|
||||
import './platform_model.dart';
|
||||
|
||||
enum SvcStatus { notReady, connecting, ready }
|
||||
|
||||
class StateGlobal {
|
||||
int _windowId = -1;
|
||||
final RxBool _fullscreen = false.obs;
|
||||
bool _isMinimized = false;
|
||||
final RxBool isMaximized = false.obs;
|
||||
final RxBool _showTabBar = true.obs;
|
||||
final RxDouble _resizeEdgeSize = RxDouble(windowResizeEdgeSize);
|
||||
final RxDouble _windowBorderWidth = RxDouble(kWindowBorderWidth);
|
||||
final RxBool showRemoteToolBar = false.obs;
|
||||
final svcStatus = SvcStatus.notReady.obs;
|
||||
final RxInt videoConnCount = 0.obs;
|
||||
final RxBool isFocused = false.obs;
|
||||
// for mobile and web
|
||||
bool isInMainPage = true;
|
||||
bool isWebVisible = true;
|
||||
|
||||
final isPortrait = false.obs;
|
||||
|
||||
final updateUrl = ''.obs;
|
||||
|
||||
String _inputSource = '';
|
||||
|
||||
// Track relative mouse mode state for each peer connection.
|
||||
// Key: peerId, Value: true if relative mouse mode is active.
|
||||
// Note: This is session-only runtime state, NOT persisted to config.
|
||||
final RxMap<String, bool> relativeMouseModeState = <String, bool>{}.obs;
|
||||
|
||||
// Use for desktop -> remote toolbar -> resolution
|
||||
final Map<String, Map<int, String?>> _lastResolutionGroupValues = {};
|
||||
|
||||
int get windowId => _windowId;
|
||||
RxBool get fullscreen => _fullscreen;
|
||||
bool get isMinimized => _isMinimized;
|
||||
double get tabBarHeight => fullscreen.isTrue ? 0 : kDesktopRemoteTabBarHeight;
|
||||
RxBool get showTabBar => _showTabBar;
|
||||
RxDouble get resizeEdgeSize => _resizeEdgeSize;
|
||||
RxDouble get windowBorderWidth => _windowBorderWidth;
|
||||
|
||||
resetLastResolutionGroupValues(String peerId) {
|
||||
_lastResolutionGroupValues[peerId] = {};
|
||||
}
|
||||
|
||||
setLastResolutionGroupValue(
|
||||
String peerId, int currentDisplay, String? value) {
|
||||
if (!_lastResolutionGroupValues.containsKey(peerId)) {
|
||||
_lastResolutionGroupValues[peerId] = {};
|
||||
}
|
||||
_lastResolutionGroupValues[peerId]![currentDisplay] = value;
|
||||
}
|
||||
|
||||
String? getLastResolutionGroupValue(String peerId, int currentDisplay) {
|
||||
return _lastResolutionGroupValues[peerId]?[currentDisplay];
|
||||
}
|
||||
|
||||
setWindowId(int id) => _windowId = id;
|
||||
setMaximized(bool v) {
|
||||
if (!_fullscreen.isTrue) {
|
||||
if (isMaximized.value != v) {
|
||||
isMaximized.value = v;
|
||||
refreshResizeEdgeSize();
|
||||
}
|
||||
if (!isMacOS) {
|
||||
_windowBorderWidth.value = v ? 0 : kWindowBorderWidth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMinimized(bool v) => _isMinimized = v;
|
||||
|
||||
setFullscreen(bool v, {bool procWnd = true}) {
|
||||
if (_fullscreen.value != v) {
|
||||
_fullscreen.value = v;
|
||||
_showTabBar.value = !_fullscreen.value;
|
||||
if (isWebDesktop) {
|
||||
procFullscreenWeb();
|
||||
} else {
|
||||
procFullscreenNative(procWnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
procFullscreenWeb() {
|
||||
final isFullscreen = ffiGetByName('fullscreen') == 'Y';
|
||||
String fullscreenValue = '';
|
||||
if (isFullscreen && _fullscreen.isFalse) {
|
||||
fullscreenValue = 'N';
|
||||
} else if (!isFullscreen && fullscreen.isTrue) {
|
||||
fullscreenValue = 'Y';
|
||||
}
|
||||
if (fullscreenValue.isNotEmpty) {
|
||||
ffiSetByName('fullscreen', fullscreenValue);
|
||||
}
|
||||
}
|
||||
|
||||
procFullscreenNative(bool procWnd) {
|
||||
refreshResizeEdgeSize();
|
||||
print("fullscreen: $fullscreen, resizeEdgeSize: ${_resizeEdgeSize.value}");
|
||||
_windowBorderWidth.value = fullscreen.isTrue ? 0 : kWindowBorderWidth;
|
||||
if (procWnd) {
|
||||
final wc = WindowController.fromWindowId(windowId);
|
||||
wc.setFullscreen(_fullscreen.isTrue).then((_) {
|
||||
// We remove the redraw (width + 1, height + 1), because this issue cannot be reproduced.
|
||||
// https://github.com/rustdesk/rustdesk/issues/9675
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
refreshResizeEdgeSize() => _resizeEdgeSize.value = fullscreen.isTrue
|
||||
? kFullScreenEdgeSize
|
||||
: isMaximized.isTrue
|
||||
? kMaximizeEdgeSize
|
||||
: windowResizeEdgeSize;
|
||||
|
||||
String getInputSource({bool force = false}) {
|
||||
if (force || _inputSource.isEmpty) {
|
||||
_inputSource = bind.mainGetInputSource();
|
||||
}
|
||||
return _inputSource;
|
||||
}
|
||||
|
||||
setInputSource(SessionID sessionId, String v) async {
|
||||
await bind.mainSetInputSource(sessionId: sessionId, value: v);
|
||||
_inputSource = bind.mainGetInputSource();
|
||||
}
|
||||
|
||||
StateGlobal._() {
|
||||
if (isWebDesktop) {
|
||||
platformFFI.setFullscreenCallback((v) {
|
||||
_fullscreen.value = v;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static final StateGlobal instance = StateGlobal._();
|
||||
}
|
||||
|
||||
// This final variable is initialized when the first time it is accessed.
|
||||
final stateGlobal = StateGlobal.instance;
|
||||
@@ -0,0 +1,497 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
|
||||
class TerminalModel with ChangeNotifier {
|
||||
final String id; // peer id
|
||||
final FFI parent;
|
||||
final int terminalId;
|
||||
late final Terminal terminal;
|
||||
late final TerminalController terminalController;
|
||||
|
||||
bool _terminalOpened = false;
|
||||
bool get terminalOpened => _terminalOpened;
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
final _inputBuffer = <String>[];
|
||||
// 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 _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 {
|
||||
// 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 && data == '\n') {
|
||||
data = '\r';
|
||||
}
|
||||
if (_terminalOpened) {
|
||||
// Send user input to remote terminal
|
||||
try {
|
||||
await bind.sessionSendTerminalInput(
|
||||
sessionId: parent.sessionId,
|
||||
terminalId: terminalId,
|
||||
data: data,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Error sending terminal input: $e');
|
||||
}
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Terminal not opened yet, buffering input');
|
||||
_inputBuffer.add(data);
|
||||
}
|
||||
}
|
||||
|
||||
TerminalModel(this.parent, [this.terminalId = 0]) : id = parent.id {
|
||||
terminal = Terminal(maxLines: 10000);
|
||||
terminalController = TerminalController();
|
||||
|
||||
// Setup terminal callbacks
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
};
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
// Validate all dimensions before using them
|
||||
if (w > 0 && h > 0 && pw > 0 && ph > 0) {
|
||||
debugPrint(
|
||||
'[TerminalModel] Terminal resized to ${w}x$h (pixel: ${pw}x$ph)');
|
||||
|
||||
// This piece of code must be placed before the conditional check in order to initialize properly.
|
||||
onResizeExternal?.call(w, h, pw, ph);
|
||||
|
||||
// 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) {
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
if (_terminalOpened) {
|
||||
// Notify remote terminal of resize
|
||||
try {
|
||||
await bind.sessionResizeTerminal(
|
||||
sessionId: parent.sessionId,
|
||||
terminalId: terminalId,
|
||||
rows: h,
|
||||
cols: w,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Error resizing terminal: $e');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'[TerminalModel] Invalid terminal dimensions: ${w}x$h (pixel: ${pw}x$ph)');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void onReady() {
|
||||
parent.dialogManager.dismissAll();
|
||||
|
||||
// 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({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
|
||||
|
||||
// Get terminal dimensions, ensuring they are valid
|
||||
int rows = 24;
|
||||
int cols = 80;
|
||||
|
||||
if (terminal.viewHeight > 0) {
|
||||
rows = terminal.viewHeight;
|
||||
}
|
||||
if (terminal.viewWidth > 0) {
|
||||
cols = terminal.viewWidth;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[TerminalModel] Opening terminal $terminalId, sessionId: ${parent.sessionId}, size: ${cols}x$rows');
|
||||
try {
|
||||
await bind
|
||||
.sessionOpenTerminal(
|
||||
sessionId: parent.sessionId,
|
||||
terminalId: terminalId,
|
||||
rows: rows,
|
||||
cols: cols,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'sessionOpenTerminal timed out after 5 seconds');
|
||||
},
|
||||
);
|
||||
debugPrint('[TerminalModel] sessionOpenTerminal called successfully');
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
|
||||
// Optionally show error to user
|
||||
if (e is TimeoutException) {
|
||||
_writeToTerminal('Failed to open terminal: Connection timeout\r\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendVirtualKey(String data) async {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
try {
|
||||
await bind
|
||||
.sessionCloseTerminal(
|
||||
sessionId: parent.sessionId,
|
||||
terminalId: terminalId,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () {
|
||||
throw TimeoutException(
|
||||
'sessionCloseTerminal timed out after 3 seconds');
|
||||
},
|
||||
);
|
||||
debugPrint('[TerminalModel] sessionCloseTerminal called successfully');
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Error calling sessionCloseTerminal: $e');
|
||||
// Continue with cleanup even if close fails
|
||||
}
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
static int getTerminalIdFromEvt(Map<String, dynamic> evt) {
|
||||
if (evt.containsKey('terminal_id')) {
|
||||
final v = evt['terminal_id'];
|
||||
if (v is int) {
|
||||
// Desktop and mobile send terminal_id as an int
|
||||
return v;
|
||||
} else if (v is String) {
|
||||
// Web sends terminal_id as a string
|
||||
final parsed = int.tryParse(v);
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
} else {
|
||||
debugPrint(
|
||||
'[TerminalModel] Failed to parse terminal_id as integer: $v. Expected a numeric string.');
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
// Unexpected type, log and handle gracefully
|
||||
debugPrint(
|
||||
'[TerminalModel] Unexpected terminal_id type: ${v.runtimeType}, value: $v. Expected int or String.');
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Event does not contain terminal_id');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static bool getSuccessFromEvt(Map<String, dynamic> evt) {
|
||||
if (evt.containsKey('success')) {
|
||||
final v = evt['success'];
|
||||
if (v is bool) {
|
||||
// Desktop and mobile
|
||||
return v;
|
||||
} else if (v is String) {
|
||||
// Web
|
||||
return v.toLowerCase() == 'true';
|
||||
} else {
|
||||
// Unexpected type, log and handle gracefully
|
||||
debugPrint(
|
||||
'[TerminalModel] Unexpected success type: ${v.runtimeType}, value: $v. Expected bool or String.');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Event does not contain success');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void handleTerminalResponse(Map<String, dynamic> evt) {
|
||||
final String? type = evt['type'];
|
||||
final int evtTerminalId = getTerminalIdFromEvt(evt);
|
||||
|
||||
// Only handle events for this terminal
|
||||
if (evtTerminalId != terminalId) {
|
||||
debugPrint(
|
||||
'[TerminalModel] Ignoring event for terminal $evtTerminalId (not mine)');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'opened':
|
||||
_handleTerminalOpened(evt);
|
||||
break;
|
||||
case 'data':
|
||||
_handleTerminalData(evt);
|
||||
break;
|
||||
case 'closed':
|
||||
_handleTerminalClosed(evt);
|
||||
break;
|
||||
case 'error':
|
||||
_handleTerminalError(evt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalOpened(Map<String, dynamic> evt) {
|
||||
final bool success = getSuccessFromEvt(evt);
|
||||
final String message = evt['message']?.toString() ?? '';
|
||||
final String? serviceId = evt['service_id']?.toString();
|
||||
|
||||
debugPrint(
|
||||
'[TerminalModel] Terminal opened response: success=$success, message=$message, service_id=$serviceId');
|
||||
|
||||
if (success) {
|
||||
_terminalOpened = true;
|
||||
|
||||
// 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),
|
||||
// mark view ready now to avoid output stuck in buffer indefinitely.
|
||||
if (!_terminalViewReady &&
|
||||
terminal.viewWidth > 0 &&
|
||||
terminal.viewHeight > 0) {
|
||||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
_processBufferedInputAsync().then((_) {
|
||||
notifyListeners();
|
||||
}).catchError((e) {
|
||||
debugPrint('[TerminalModel] Error processing buffered input: $e');
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
final persistentSessions =
|
||||
(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,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
_writeToTerminal('Failed to open terminal: $message\r\n');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processBufferedInputAsync() async {
|
||||
final buffer = List<String>.from(_inputBuffer);
|
||||
_inputBuffer.clear();
|
||||
|
||||
for (final data in buffer) {
|
||||
try {
|
||||
await bind.sessionSendTerminalInput(
|
||||
sessionId: parent.sessionId,
|
||||
terminalId: terminalId,
|
||||
data: data,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Error sending buffered input: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalData(Map<String, dynamic> evt) {
|
||||
final data = evt['data'];
|
||||
|
||||
if (data != null) {
|
||||
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
|
||||
_suppressNextTerminalDataOutput = false;
|
||||
try {
|
||||
String text = '';
|
||||
if (data is String) {
|
||||
// Try to decode as base64 first
|
||||
try {
|
||||
final bytes = base64Decode(data);
|
||||
text = utf8.decode(bytes, allowMalformed: true);
|
||||
} catch (e) {
|
||||
// If base64 decode fails, treat as plain text
|
||||
text = data;
|
||||
}
|
||||
} else if (data is List) {
|
||||
// Handle if data comes as byte array
|
||||
text = utf8.decode(List<int>.from(data), allowMalformed: true);
|
||||
} else {
|
||||
debugPrint('[TerminalModel] Unknown data type: ${data.runtimeType}');
|
||||
return;
|
||||
}
|
||||
|
||||
_writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalModel] Failed to process terminal data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, {
|
||||
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,
|
||||
// which can cause a brief visual glitch on flush. This is acceptable
|
||||
// because it only affects the pre-layout buffering window and the
|
||||
// terminal will self-correct on subsequent output.
|
||||
if (text.length >= _kMaxOutputBufferChars) {
|
||||
final truncated = text.substring(text.length - _kMaxOutputBufferChars);
|
||||
_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;
|
||||
}
|
||||
_writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
|
||||
}
|
||||
|
||||
void _flushOutputBuffer() {
|
||||
if (_pendingOutputChunks.isEmpty) return;
|
||||
debugPrint(
|
||||
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
|
||||
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;
|
||||
_flushOutputBuffer();
|
||||
}
|
||||
|
||||
void _handleTerminalClosed(Map<String, dynamic> evt) {
|
||||
final int exitCode = evt['exit_code'] ?? 0;
|
||||
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||
final String message = evt['message'] ?? 'Unknown error';
|
||||
_writeToTerminal('\r\nTerminal error: $message\r\n');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:bot_toast/bot_toast.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
|
||||
import 'package:flutter_hbb/models/ab_model.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../utils/http_service.dart' as http;
|
||||
import 'model.dart';
|
||||
import 'platform_model.dart';
|
||||
|
||||
bool refreshingUser = false;
|
||||
|
||||
class UserModel {
|
||||
final RxString userName = ''.obs;
|
||||
final RxString displayName = ''.obs;
|
||||
final RxString avatar = ''.obs;
|
||||
final RxBool isAdmin = false.obs;
|
||||
final RxString networkError = ''.obs;
|
||||
bool get isLogin => userName.isNotEmpty;
|
||||
String get displayNameOrUserName =>
|
||||
displayName.value.trim().isEmpty ? userName.value : displayName.value;
|
||||
String get accountLabelWithHandle {
|
||||
final username = userName.value.trim();
|
||||
if (username.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final preferred = displayName.value.trim();
|
||||
if (preferred.isEmpty || preferred == username) {
|
||||
return username;
|
||||
}
|
||||
return '$preferred (@$username)';
|
||||
}
|
||||
|
||||
WeakReference<FFI> parent;
|
||||
|
||||
UserModel(this.parent) {
|
||||
userName.listen((p0) {
|
||||
// When user name becomes empty, show login button
|
||||
// When user name becomes non-empty:
|
||||
// For _updateLocalUserInfo, network error will be set later
|
||||
// For login success, should clear network error
|
||||
networkError.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
void refreshCurrentUser() async {
|
||||
if (bind.isDisableAccount()) return;
|
||||
networkError.value = '';
|
||||
final token = bind.mainGetLocalOption(key: 'access_token');
|
||||
if (token == '') {
|
||||
await updateOtherModels();
|
||||
return;
|
||||
}
|
||||
_updateLocalUserInfo();
|
||||
final url = await bind.mainGetApiServer();
|
||||
final body = {
|
||||
'id': await bind.mainGetMyId(),
|
||||
'uuid': await bind.mainGetUuid()
|
||||
};
|
||||
if (refreshingUser) return;
|
||||
try {
|
||||
refreshingUser = true;
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await http.post(Uri.parse('$url/api/currentUser'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token'
|
||||
},
|
||||
body: json.encode(body));
|
||||
} catch (e) {
|
||||
networkError.value = e.toString();
|
||||
rethrow;
|
||||
}
|
||||
refreshingUser = false;
|
||||
final status = response.statusCode;
|
||||
if (status == 401 || status == 400) {
|
||||
reset(resetOther: status == 401);
|
||||
return;
|
||||
}
|
||||
final data = json.decode(decode_http_response(response));
|
||||
final error = data['error'];
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
final user = UserPayload.fromJson(data);
|
||||
_parseAndUpdateUser(user);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to refreshCurrentUser: $e');
|
||||
} finally {
|
||||
refreshingUser = false;
|
||||
await updateOtherModels();
|
||||
}
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? getLocalUserInfo() {
|
||||
final userInfo = bind.mainGetLocalOption(key: 'user_info');
|
||||
if (userInfo == '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return json.decode(userInfo);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to get local user info "$userInfo": $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_updateLocalUserInfo() {
|
||||
final userInfo = getLocalUserInfo();
|
||||
if (userInfo != null) {
|
||||
userName.value = (userInfo['name'] ?? '').toString();
|
||||
displayName.value = (userInfo['display_name'] ?? '').toString();
|
||||
avatar.value = (userInfo['avatar'] ?? '').toString();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset({bool resetOther = false}) async {
|
||||
await bind.mainSetLocalOption(key: 'access_token', value: '');
|
||||
await bind.mainSetLocalOption(key: 'user_info', value: '');
|
||||
if (resetOther) {
|
||||
await gFFI.abModel.reset();
|
||||
await gFFI.groupModel.reset();
|
||||
}
|
||||
userName.value = '';
|
||||
displayName.value = '';
|
||||
avatar.value = '';
|
||||
}
|
||||
|
||||
_parseAndUpdateUser(UserPayload user) {
|
||||
userName.value = user.name;
|
||||
displayName.value = user.displayName;
|
||||
avatar.value = user.avatar;
|
||||
isAdmin.value = user.isAdmin;
|
||||
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
|
||||
if (isWeb) {
|
||||
// ugly here, tmp solution
|
||||
bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
// update ab and group status
|
||||
static Future<void> updateOtherModels() async {
|
||||
await Future.wait([
|
||||
gFFI.abModel.pullAb(force: ForcePullAb.listAndCurrent, quiet: false),
|
||||
gFFI.groupModel.pull()
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> logOut({String? apiServer}) async {
|
||||
final tag = gFFI.dialogManager.showLoading(translate('Waiting'));
|
||||
try {
|
||||
final url = apiServer ?? await bind.mainGetApiServer();
|
||||
final authHeaders = getHttpHeaders();
|
||||
authHeaders['Content-Type'] = "application/json";
|
||||
await http
|
||||
.post(Uri.parse('$url/api/logout'),
|
||||
body: jsonEncode({
|
||||
'id': await bind.mainGetMyId(),
|
||||
'uuid': await bind.mainGetUuid(),
|
||||
}),
|
||||
headers: authHeaders)
|
||||
.timeout(Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint("request /api/logout failed: err=$e");
|
||||
} finally {
|
||||
await reset(resetOther: true);
|
||||
gFFI.dialogManager.dismissByTag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// throw [RequestException]
|
||||
Future<LoginResponse> login(LoginRequest loginRequest) async {
|
||||
final url = await bind.mainGetApiServer();
|
||||
final resp = await http.post(Uri.parse('$url/api/login'),
|
||||
body: jsonEncode(loginRequest.toJson()));
|
||||
|
||||
final Map<String, dynamic> body;
|
||||
try {
|
||||
body = jsonDecode(decode_http_response(resp));
|
||||
} catch (e) {
|
||||
debugPrint("login: jsonDecode resp body failed: ${e.toString()}");
|
||||
if (resp.statusCode != 200) {
|
||||
BotToast.showText(
|
||||
contentColor: Colors.red, text: 'HTTP ${resp.statusCode}');
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
throw RequestException(resp.statusCode, body['error'] ?? '');
|
||||
}
|
||||
if (body['error'] != null) {
|
||||
throw RequestException(0, body['error']);
|
||||
}
|
||||
|
||||
return getLoginResponseFromAuthBody(body);
|
||||
}
|
||||
|
||||
LoginResponse getLoginResponseFromAuthBody(Map<String, dynamic> body) {
|
||||
final LoginResponse loginResponse;
|
||||
try {
|
||||
loginResponse = LoginResponse.fromJson(body);
|
||||
} catch (e) {
|
||||
debugPrint("login: jsonDecode LoginResponse failed: ${e.toString()}");
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final isLogInDone = loginResponse.type == HttpType.kAuthResTypeToken &&
|
||||
loginResponse.access_token != null;
|
||||
if (isLogInDone && loginResponse.user != null) {
|
||||
_parseAndUpdateUser(loginResponse.user!);
|
||||
}
|
||||
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
static Future<List<dynamic>> queryOidcLoginOptions() async {
|
||||
try {
|
||||
final url = await bind.mainGetApiServer();
|
||||
if (url.trim().isEmpty) return [];
|
||||
final resp = await http.get(Uri.parse('$url/api/login-options'));
|
||||
final List<String> ops = [];
|
||||
for (final item in jsonDecode(resp.body)) {
|
||||
ops.add(item as String);
|
||||
}
|
||||
for (final item in ops) {
|
||||
if (item.startsWith('common-oidc/')) {
|
||||
return jsonDecode(item.substring('common-oidc/'.length));
|
||||
}
|
||||
}
|
||||
return ops
|
||||
.where((item) => item.startsWith('oidc/'))
|
||||
.map((item) => {'name': item.substring('oidc/'.length)})
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
"queryOidcLoginOptions: jsonDecode resp body failed: ${e.toString()}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:js';
|
||||
import 'dart:html';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/common/widgets/login.dart';
|
||||
import 'package:flutter_hbb/models/state_model.dart';
|
||||
|
||||
import 'package:flutter_hbb/web/bridge.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
|
||||
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
|
||||
|
||||
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
||||
|
||||
class PlatformFFI {
|
||||
final _eventHandlers = <String, Map<String, HandleEvent>>{};
|
||||
final RustdeskImpl _ffiBind = RustdeskImpl();
|
||||
|
||||
static String getByName(String name, [String arg = '']) {
|
||||
return context.callMethod('getByName', [name, arg]);
|
||||
}
|
||||
|
||||
static void setByName(String name, [String value = '']) {
|
||||
context.callMethod('setByName', [name, value]);
|
||||
}
|
||||
|
||||
PlatformFFI._() {
|
||||
window.document.addEventListener(
|
||||
'visibilitychange',
|
||||
(event) => {
|
||||
stateGlobal.isWebVisible =
|
||||
window.document.visibilityState == 'visible'
|
||||
});
|
||||
}
|
||||
|
||||
static final PlatformFFI instance = PlatformFFI._();
|
||||
|
||||
static get localeName => window.navigator.language;
|
||||
RustdeskImpl get ffiBind => _ffiBind;
|
||||
|
||||
static Future<String> getVersion() async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
bool registerEventHandler(
|
||||
String eventName, String handlerName, HandleEvent handler,
|
||||
{bool replace = false}) {
|
||||
debugPrint('registerEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers == null) {
|
||||
_eventHandlers[eventName] = {handlerName: handler};
|
||||
return true;
|
||||
} else {
|
||||
if (!replace && handlers.containsKey(handlerName)) {
|
||||
return false;
|
||||
} else {
|
||||
handlers[handlerName] = handler;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void unregisterEventHandler(String eventName, String handlerName) {
|
||||
debugPrint('unregisterEventHandler $eventName $handlerName');
|
||||
var handlers = _eventHandlers[eventName];
|
||||
if (handlers != null) {
|
||||
handlers.remove(handlerName);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> tryHandle(Map<String, dynamic> evt) async {
|
||||
final name = evt['name'];
|
||||
if (name != null) {
|
||||
final handlers = _eventHandlers[name];
|
||||
if (handlers != null) {
|
||||
if (handlers.isNotEmpty) {
|
||||
for (var handler in handlers.values) {
|
||||
await handler(evt);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
String translate(String name, String locale) =>
|
||||
_ffiBind.translate(name: name, locale: locale);
|
||||
|
||||
Uint8List? getRgba(SessionID sessionId, int display, int bufSize) {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
int getRgbaSize(SessionID sessionId, int display) =>
|
||||
_ffiBind.sessionGetRgbaSize(sessionId: sessionId, display: display);
|
||||
void nextRgba(SessionID sessionId, int display) =>
|
||||
_ffiBind.sessionNextRgba(sessionId: sessionId, display: display);
|
||||
void registerPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterPixelbufferTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
||||
_ffiBind.sessionRegisterGpuTexture(
|
||||
sessionId: sessionId, display: display, ptr: ptr);
|
||||
|
||||
Future<void> init(String appType) async {
|
||||
Completer completer = Completer();
|
||||
context["onInitFinished"] = () {
|
||||
completer.complete();
|
||||
};
|
||||
context['dialog'] = (type, title, text) {
|
||||
final uuid = Uuid();
|
||||
msgBox(SessionID(uuid.v4()), type, title, text, '', gFFI.dialogManager);
|
||||
};
|
||||
context['loginDialog'] = () {
|
||||
loginDialog();
|
||||
};
|
||||
context['closeConnection'] = () {
|
||||
gFFI.dialogManager.dismissAll();
|
||||
closeConnection();
|
||||
};
|
||||
context.callMethod('init');
|
||||
version = getByName('version');
|
||||
window.onContextMenu.listen((event) {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
context['onRegisteredEvent'] = (String message) {
|
||||
try {
|
||||
Map<String, dynamic> event = json.decode(message);
|
||||
tryHandle(event);
|
||||
} catch (e) {
|
||||
print('json.decode fail(): $e');
|
||||
}
|
||||
};
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void setEventCallback(void Function(Map<String, dynamic>) fun) {
|
||||
context["onGlobalEvent"] = (String message) {
|
||||
try {
|
||||
Map<String, dynamic> event = json.decode(message);
|
||||
fun(event);
|
||||
} catch (e) {
|
||||
print('json.decode fail(): $e');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void setRgbaCallback(void Function(int, Uint8List) fun) {
|
||||
context["onRgba"] = (int display, Uint8List? rgba) {
|
||||
if (rgba != null) {
|
||||
fun(display, rgba);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
void startDesktopWebListener() {
|
||||
mouseListeners.add(
|
||||
window.document.onContextMenu.listen((evt) => evt.preventDefault()));
|
||||
}
|
||||
|
||||
void stopDesktopWebListener() {
|
||||
for (var ml in mouseListeners) {
|
||||
ml.cancel();
|
||||
}
|
||||
mouseListeners.clear();
|
||||
for (var kl in keyListeners) {
|
||||
kl.cancel();
|
||||
}
|
||||
keyListeners.clear();
|
||||
}
|
||||
|
||||
void setMethodCallHandler(FMethod callback) {}
|
||||
|
||||
invokeMethod(String method, [dynamic arguments]) async {
|
||||
return true;
|
||||
}
|
||||
|
||||
// just for compilation
|
||||
void syncAndroidServiceAppDirConfigPath() {}
|
||||
|
||||
void setFullscreenCallback(void Function(bool) fun) {
|
||||
context["onFullscreenChanged"] = (bool v) {
|
||||
fun(v);
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user