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:
xuwenwei
2026-06-08 21:42:20 +08:00
parent 3d8db09123
commit d3b6026bfd
840 changed files with 291780 additions and 1 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class IDTextEditingController extends TextEditingController {
IDTextEditingController({String? text}) : super(text: text);
String get id => trimID(value.text);
set id(String newID) => text = formatID(newID);
}
class IDTextInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
if (newValue.text.isEmpty) {
return newValue.copyWith(text: '');
} else if (newValue.text.compareTo(oldValue.text) == 0) {
return newValue;
} else {
int selectionIndexFromTheRight =
newValue.text.length - newValue.selection.extentOffset;
String newID = formatID(newValue.text);
return TextEditingValue(
text: newID,
selection: TextSelection.collapsed(
offset: newID.length - selectionIndexFromTheRight,
),
// https://github.com/flutter/flutter/issues/78066#issuecomment-797869906
composing: newValue.composing,
);
}
}
}
String formatID(String id) {
String id2 = id.replaceAll(' ', '');
String suffix = '';
if (id2.endsWith(r'\r') || id2.endsWith(r'/r')) {
suffix = id2.substring(id2.length - 2, id2.length);
id2 = id2.substring(0, id2.length - 2);
}
if (int.tryParse(id2) == null) return id;
String newID = '';
if (id2.length <= 3) {
newID = id2;
} else {
var n = id2.length;
var a = n % 3 != 0 ? n % 3 : 3;
newID = id2.substring(0, a);
for (var i = a; i < n; i += 3) {
newID += " ${id2.substring(i, i + 3)}";
}
}
return newID + suffix;
}
String trimID(String id) {
return id.replaceAll(' ', '');
}
+302
View File
@@ -0,0 +1,302 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import '../../models/platform_model.dart';
class HttpType {
static const kAuthReqTypeAccount = "account";
static const kAuthReqTypeMobile = "mobile";
static const kAuthReqTypeSMSCode = "sms_code";
static const kAuthReqTypeEmailCode = "email_code";
static const kAuthReqTypeTfaCode = "tfa_code";
static const kAuthResTypeToken = "access_token";
static const kAuthResTypeEmailCheck = "email_check";
static const kAuthResTypeTfaCheck = "tfa_check";
}
enum UserStatus { kDisabled, kNormal, kUnverified }
// to-do: The UserPayload does not contain all the fields of the user.
// Is all the fields of the user needed?
class UserPayload {
String name = '';
String displayName = '';
String avatar = '';
String email = '';
String note = '';
String? verifier;
UserStatus status;
bool isAdmin = false;
UserPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '',
displayName = json['display_name'] ?? '',
avatar = json['avatar'] ?? '',
email = json['email'] ?? '',
note = json['note'] ?? '',
verifier = json['verifier'],
status = json['status'] == 0
? UserStatus.kDisabled
: json['status'] == -1
? UserStatus.kUnverified
: UserStatus.kNormal,
isAdmin = json['is_admin'] == true;
Map<String, dynamic> toJson() {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
'avatar': avatar,
'status': status == UserStatus.kDisabled
? 0
: status == UserStatus.kUnverified
? -1
: 1,
};
return map;
}
Map<String, dynamic> toGroupCacheJson() {
final Map<String, dynamic> map = {
'name': name,
'display_name': displayName,
};
return map;
}
String get displayNameOrName {
return displayName.trim().isEmpty ? name : displayName;
}
}
class PeerPayload {
String id = '';
Map<String, dynamic> info = {};
int? status;
String user = '';
String user_name = '';
String? device_group_name;
String note = '';
PeerPayload.fromJson(Map<String, dynamic> json)
: id = json['id'] ?? '',
info = (json['info'] is Map<String, dynamic>) ? json['info'] : {},
status = json['status'],
user = json['user'] ?? '',
user_name = json['user_name'] ?? '',
device_group_name = json['device_group_name'] ?? '',
note = json['note'] ?? '';
static Peer toPeer(PeerPayload p) {
return Peer.fromJson({
"id": p.id,
'loginName': p.user_name,
"username": p.info['username'] ?? '',
"platform": _platform(p.info['os']),
"hostname": p.info['device_name'],
"device_group_name": p.device_group_name,
"note": p.note,
});
}
static String? _platform(dynamic field) {
if (field == null) {
return null;
}
final fieldStr = field.toString();
List<String> list = fieldStr.split(' / ');
if (list.isEmpty) return null;
final os = list[0];
switch (os.toLowerCase()) {
case 'windows':
return kPeerPlatformWindows;
case 'linux':
return kPeerPlatformLinux;
case 'macos':
return kPeerPlatformMacOS;
case 'android':
return kPeerPlatformAndroid;
default:
if (fieldStr.toLowerCase().contains('linux')) {
return kPeerPlatformLinux;
}
return null;
}
}
}
class LoginRequest {
String? username;
String? password;
String? id;
String? uuid;
bool? autoLogin;
String? type;
String? verificationCode;
String? tfaCode;
String? secret;
LoginRequest(
{this.username,
this.password,
this.id,
this.uuid,
this.autoLogin,
this.type,
this.verificationCode,
this.tfaCode,
this.secret});
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
if (username != null) data['username'] = username;
if (password != null) data['password'] = password;
if (id != null) data['id'] = id;
if (uuid != null) data['uuid'] = uuid;
if (autoLogin != null) data['autoLogin'] = autoLogin;
if (type != null) data['type'] = type;
if (verificationCode != null) {
data['verificationCode'] = verificationCode;
}
if (tfaCode != null) data['tfaCode'] = tfaCode;
if (secret != null) data['secret'] = secret;
Map<String, dynamic> deviceInfo = {};
try {
deviceInfo = jsonDecode(bind.mainGetLoginDeviceInfo());
} catch (e) {
debugPrint('Failed to decode get device info: $e');
}
data['deviceInfo'] = deviceInfo;
return data;
}
}
class LoginResponse {
String? access_token;
String? type;
String? tfa_type;
String? secret;
UserPayload? user;
LoginResponse(
{this.access_token, this.type, this.tfa_type, this.secret, this.user});
LoginResponse.fromJson(Map<String, dynamic> json) {
access_token = json['access_token'];
type = json['type'];
tfa_type = json['tfa_type'];
secret = json['secret'];
user = json['user'] != null ? UserPayload.fromJson(json['user']) : null;
}
}
class RequestException implements Exception {
int statusCode;
String cause;
RequestException(this.statusCode, this.cause);
@override
String toString() {
return "RequestException, statusCode: $statusCode, error: $cause";
}
}
enum ShareRule {
read(1),
readWrite(2),
fullControl(3);
const ShareRule(this.value);
final int value;
static String desc(int v) {
if (v == ShareRule.read.value) {
return translate('Read-only');
}
if (v == ShareRule.readWrite.value) {
return translate('Read/Write');
}
if (v == ShareRule.fullControl.value) {
return translate('Full Control');
}
return v.toString();
}
static String shortDesc(int v) {
if (v == ShareRule.read.value) {
return 'R';
}
if (v == ShareRule.readWrite.value) {
return 'RW';
}
if (v == ShareRule.fullControl.value) {
return 'F';
}
return v.toString();
}
static ShareRule? fromValue(int v) {
if (v == ShareRule.read.value) {
return ShareRule.read;
}
if (v == ShareRule.readWrite.value) {
return ShareRule.readWrite;
}
if (v == ShareRule.fullControl.value) {
return ShareRule.fullControl;
}
return null;
}
}
class AbProfile {
String guid;
String name;
String owner;
String? note;
dynamic info;
int rule;
AbProfile(this.guid, this.name, this.owner, this.note, this.rule, this.info);
AbProfile.fromJson(Map<String, dynamic> json)
: guid = json['guid'] ?? '',
name = json['name'] ?? '',
owner = json['owner'] ?? '',
note = json['note'] ?? '',
info = json['info'],
rule = json['rule'] ?? 0;
}
class AbTag {
String name;
int color;
AbTag(this.name, this.color);
AbTag.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '',
color = json['color'] ?? '';
}
class DeviceGroupPayload {
String name;
DeviceGroupPayload(this.name);
DeviceGroupPayload.fromJson(Map<String, dynamic> json)
: name = json['name'] ?? '';
Map<String, dynamic> toGroupCacheJson() {
final Map<String, dynamic> map = {
'name': name,
};
return map;
}
}
+368
View File
@@ -0,0 +1,368 @@
import 'package:flutter_hbb/common.dart';
import 'package:get/get.dart';
import '../consts.dart';
// TODO: A lot of dup code.
class PrivacyModeState {
static String tag(String id) => 'privacy_mode_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxString>(tag: key)) {
final RxString state = ''.obs;
Get.put<RxString>(state, tag: key);
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxString>(tag: key)) {
Get.delete<RxString>(tag: key);
} else {
Get.find<RxString>(tag: key).value = '';
}
}
static RxString find(String id) => Get.find<RxString>(tag: tag(id));
}
class BlockInputState {
static String tag(String id) => 'block_input_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
}
class CurrentDisplayState {
static String tag(String id) => 'current_display_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = RxInt(0);
Get.put<RxInt>(state, tag: key);
} else {
Get.find<RxInt>(tag: key).value = 0;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete<RxInt>(tag: key);
}
}
static RxInt find(String id) => Get.find<RxInt>(tag: tag(id));
}
class ConnectionType {
final Rx<String> _secure = kInvalidValueStr.obs;
final Rx<String> _direct = kInvalidValueStr.obs;
final Rx<String> _stream_type = kInvalidValueStr.obs;
Rx<String> get secure => _secure;
Rx<String> get direct => _direct;
Rx<String> get stream_type => _stream_type;
static String get strSecure => 'secure';
static String get strInsecure => 'insecure';
static String get strDirect => '';
static String get strIndirect => '_relay';
void setSecure(bool v) {
_secure.value = v ? strSecure : strInsecure;
}
void setDirect(bool v) {
_direct.value = v ? strDirect : strIndirect;
}
void setStreamType(String v) {
_stream_type.value = v;
}
bool isValid() {
return _secure.value != kInvalidValueStr &&
_direct.value != kInvalidValueStr &&
_stream_type.value != kInvalidValueStr;
}
}
class ConnectionTypeState {
static String tag(String id) => 'connection_type_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<ConnectionType>(tag: key)) {
final ConnectionType collectionType = ConnectionType();
Get.put<ConnectionType>(collectionType, tag: key);
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<ConnectionType>(tag: key)) {
Get.delete<ConnectionType>(tag: key);
}
}
static ConnectionType find(String id) =>
Get.find<ConnectionType>(tag: tag(id));
}
class FingerprintState {
static String tag(String id) => 'fingerprint_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxString>(tag: key)) {
final RxString state = ''.obs;
Get.put<RxString>(state, tag: key);
} else {
Get.find<RxString>(tag: key).value = '';
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxString>(tag: key)) {
Get.delete<RxString>(tag: key);
}
}
static RxString find(String id) => Get.find<RxString>(tag: tag(id));
}
class ShowRemoteCursorState {
static String tag(String id) => 'show_remote_cursor_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
}
class ShowRemoteCursorLockState {
static String tag(String id) => 'show_remote_cursor_lock_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
}
class KeyboardEnabledState {
static String tag(String id) => 'keyboard_enabled_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxBool>(tag: key)) {
// Server side, default true
final RxBool state = true.obs;
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = true;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
}
class RemoteCursorMovedState {
static String tag(String id) => 'remote_cursor_moved_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool state = false.obs;
Get.put<RxBool>(state, tag: key);
} else {
Get.find<RxBool>(tag: key).value = false;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
static RxBool find(String id) => Get.find<RxBool>(tag: tag(id));
}
class RemoteCountState {
static String tag() => 'remote_count_';
static void init() {
final key = tag();
if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = 1.obs;
Get.put<RxInt>(state, tag: key);
} else {
Get.find<RxInt>(tag: key).value = 1;
}
}
static void delete() {
final key = tag();
if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete<RxInt>(tag: key);
}
}
static RxInt find() => Get.find<RxInt>(tag: tag());
}
class PeerBoolOption {
static String tag(String id, String opt) => 'peer_{$opt}_$id';
static void init(String id, String opt, bool Function() init_getter) {
final key = tag(id, opt);
if (!Get.isRegistered<RxBool>(tag: key)) {
final RxBool value = RxBool(init_getter());
Get.put<RxBool>(value, tag: key);
} else {
Get.find<RxBool>(tag: key).value = init_getter();
}
}
static void delete(String id, String opt) {
final key = tag(id, opt);
if (Get.isRegistered<RxBool>(tag: key)) {
Get.delete<RxBool>(tag: key);
}
}
static RxBool find(String id, String opt) =>
Get.find<RxBool>(tag: tag(id, opt));
}
class PeerStringOption {
static String tag(String id, String opt) => 'peer_{$opt}_$id';
static void init(String id, String opt, String Function() init_getter) {
final key = tag(id, opt);
if (!Get.isRegistered<RxString>(tag: key)) {
final RxString value = RxString(init_getter());
Get.put<RxString>(value, tag: key);
} else {
Get.find<RxString>(tag: key).value = init_getter();
}
}
static void delete(String id, String opt) {
final key = tag(id, opt);
if (Get.isRegistered<RxString>(tag: key)) {
Get.delete<RxString>(tag: key);
}
}
static RxString find(String id, String opt) =>
Get.find<RxString>(tag: tag(id, opt));
}
class UnreadChatCountState {
static String tag(id) => 'unread_chat_count_$id';
static void init(String id) {
final key = tag(id);
if (!Get.isRegistered<RxInt>(tag: key)) {
final RxInt state = RxInt(0);
Get.put<RxInt>(state, tag: key);
} else {
Get.find<RxInt>(tag: key).value = 0;
}
}
static void delete(String id) {
final key = tag(id);
if (Get.isRegistered<RxInt>(tag: key)) {
Get.delete<RxInt>(tag: key);
}
}
static RxInt find(String id) => Get.find<RxInt>(tag: tag(id));
}
initSharedStates(String id) {
PrivacyModeState.init(id);
BlockInputState.init(id);
CurrentDisplayState.init(id);
KeyboardEnabledState.init(id);
ShowRemoteCursorState.init(id);
ShowRemoteCursorLockState.init(id);
RemoteCursorMovedState.init(id);
FingerprintState.init(id);
PeerBoolOption.init(id, kOptionZoomCursor, () => false);
UnreadChatCountState.init(id);
if (isMobile) ConnectionTypeState.init(id); // desktop in other places
}
removeSharedStates(String id) {
PrivacyModeState.delete(id);
BlockInputState.delete(id);
CurrentDisplayState.delete(id);
ShowRemoteCursorState.delete(id);
ShowRemoteCursorLockState.delete(id);
KeyboardEnabledState.delete(id);
RemoteCursorMovedState.delete(id);
FingerprintState.delete(id);
PeerBoolOption.delete(id, kOptionZoomCursor);
UnreadChatCountState.delete(id);
if (isMobile) ConnectionTypeState.delete(id);
}
@@ -0,0 +1,899 @@
import 'dart:math';
import 'package:bot_toast/bot_toast.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:dynamic_layouts/dynamic_layouts.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/common/widgets/peer_card.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
import 'package:get/get.dart';
import 'package:flex_color_picker/flex_color_picker.dart';
import '../../common.dart';
import 'dialog.dart';
import 'login.dart';
final hideAbTagsPanel = false.obs;
class AddressBook extends StatefulWidget {
final EdgeInsets? menuPadding;
const AddressBook({Key? key, this.menuPadding}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _AddressBookState();
}
}
class _AddressBookState extends State<AddressBook> {
var menuPos = RelativeRect.fill;
@override
Widget build(BuildContext context) => Obx(() {
if (!gFFI.userModel.isLogin) {
return Center(
child: ElevatedButton(
onPressed: loginDialog, child: Text(translate("Login"))));
} else if (gFFI.userModel.networkError.isNotEmpty) {
return netWorkErrorWidget();
} else {
return Column(
children: [
// NOT use Offstage to wrap LinearProgressIndicator
if (gFFI.abModel.currentAbLoading.value &&
gFFI.abModel.currentAbEmpty)
const LinearProgressIndicator(),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.abPullError,
retry: null,
close: gFFI.abModel.clearPullErrors),
buildErrorBanner(context,
loading: gFFI.abModel.currentAbLoading,
err: gFFI.abModel.currentAbPushError,
retry: null, // remove retry
close: () => gFFI.abModel.currentAbPushError.value = ''),
Expanded(
child: Obx(() => stateGlobal.isPortrait.isTrue
? _buildAddressBookPortrait()
: _buildAddressBookLandscape()),
),
],
);
}
});
Widget _buildAddressBookLandscape() {
return Row(
children: [
Offstage(
offstage: hideAbTagsPanel.value,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.background)),
child: Container(
width: 200,
height: double.infinity,
child: Column(
children: [
_buildAbDropdown(),
_buildTagHeader().marginOnly(
left: 8.0,
right: gFFI.abModel.legacyMode.value ? 8.0 : 0,
top: gFFI.abModel.legacyMode.value ? 8.0 : 0),
Expanded(
child: Container(
width: double.infinity,
height: double.infinity,
child: _buildTags(),
),
),
_buildAbPermission(),
],
),
),
).marginOnly(right: 12.0)),
_buildPeersViews()
],
);
}
Widget _buildAddressBookPortrait() {
const padding = 8.0;
return Column(
children: [
Offstage(
offstage: hideAbTagsPanel.value,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Theme.of(context).colorScheme.background)),
child: Container(
padding:
const EdgeInsets.fromLTRB(padding, 0, padding, padding),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildAbDropdown(),
_buildTagHeader().marginOnly(left: 8.0, right: 0),
Container(
width: double.infinity,
child: _buildTags(),
),
],
),
),
).marginOnly(bottom: 12.0)),
_buildPeersViews()
],
);
}
Widget _buildAbPermission() {
icon(IconData data, String tooltip) {
return Tooltip(
message: translate(tooltip),
waitDuration: Duration.zero,
child: Icon(data, size: 12.0).marginSymmetric(horizontal: 2.0));
}
return Obx(() {
if (gFFI.abModel.legacyMode.value) return Offstage();
if (gFFI.abModel.current.isPersonal()) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
icon(Icons.cloud_off, "Personal"),
],
);
} else {
List<Widget> children = [];
final rule = gFFI.abModel.current.sharedProfile()?.rule;
if (rule == ShareRule.read.value) {
children.add(
icon(Icons.visibility, ShareRule.desc(ShareRule.read.value)));
} else if (rule == ShareRule.readWrite.value) {
children
.add(icon(Icons.edit, ShareRule.desc(ShareRule.readWrite.value)));
} else if (rule == ShareRule.fullControl.value) {
children.add(icon(
Icons.security, ShareRule.desc(ShareRule.fullControl.value)));
}
final owner = gFFI.abModel.current.sharedProfile()?.owner;
if (owner != null) {
children.add(icon(Icons.person, "${translate("Owner")}: $owner"));
}
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: children,
);
}
});
}
Widget _buildAbDropdown() {
if (gFFI.abModel.legacyMode.value) {
return Offstage();
}
final names = gFFI.abModel.addressBookNames();
if (!names.contains(gFFI.abModel.currentName.value)) {
return Offstage();
}
// order: personal, divider, character order
// https://pub.dev/packages/dropdown_button2#3-dropdownbutton2-with-items-of-different-heights-like-dividers
final personalAddressBookName = gFFI.abModel.personalAddressBookName();
bool contains = names.remove(personalAddressBookName);
names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
if (contains) {
names.insert(0, personalAddressBookName);
}
Row buildItem(String e, {bool button = false}) {
return Row(
children: [
Expanded(
child: Tooltip(
waitDuration: Duration(milliseconds: 500),
message: gFFI.abModel.translatedName(e),
child: Text(
gFFI.abModel.translatedName(e),
style: button ? null : TextStyle(fontSize: 14.0),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: button ? TextAlign.center : null,
)),
),
],
);
}
final items = names
.map((e) => DropdownMenuItem(value: e, child: buildItem(e)))
.toList();
var menuItemStyleData = MenuItemStyleData(height: 36);
if (contains && items.length > 1) {
items.insert(1, DropdownMenuItem(enabled: false, child: Divider()));
List<double> customHeights = List.filled(items.length, 36);
customHeights[1] = 4;
menuItemStyleData = MenuItemStyleData(customHeights: customHeights);
}
final TextEditingController textEditingController = TextEditingController();
final isOptFixed = isOptionFixed(kOptionCurrentAbName);
return DropdownButton2<String>(
value: gFFI.abModel.currentName.value,
onChanged: isOptFixed
? null
: (value) {
if (value != null) {
gFFI.abModel.setCurrentName(value);
bind.setLocalFlutterOption(k: kOptionCurrentAbName, v: value);
}
},
customButton: Obx(() => Container(
height: stateGlobal.isPortrait.isFalse ? 48 : 40,
child: Row(children: [
Expanded(
child:
buildItem(gFFI.abModel.currentName.value, button: true)),
Icon(Icons.arrow_drop_down),
]),
)),
underline: Container(
height: 0.7,
color: Theme.of(context).dividerColor.withOpacity(0.1),
),
menuItemStyleData: menuItemStyleData,
items: items,
isExpanded: true,
isDense: true,
dropdownSearchData: DropdownSearchData(
searchController: textEditingController,
searchInnerWidgetHeight: 50,
searchInnerWidget: Container(
height: 50,
padding: const EdgeInsets.only(
top: 8,
bottom: 4,
right: 8,
left: 8,
),
child: TextFormField(
expands: true,
maxLines: null,
controller: textEditingController,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
hintText: translate('Search'),
hintStyle: const TextStyle(fontSize: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
).workaroundFreezeLinuxMint(),
),
searchMatchFn: (item, searchValue) {
return item.value
.toString()
.toLowerCase()
.contains(searchValue.toLowerCase());
},
),
);
}
Widget _buildTagHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(translate('Tags')),
Listener(
onPointerDown: (e) {
final x = e.position.dx;
final y = e.position.dy;
menuPos = RelativeRect.fromLTRB(x, y, x, y);
},
onPointerUp: (_) => _showMenu(menuPos),
child: build_more(context, invert: true)),
],
);
}
Widget _buildTags() {
return Obx(() {
List tags;
if (gFFI.abModel.sortTags.value) {
tags = gFFI.abModel.currentAbTags.toList();
tags.sort();
} else {
tags = gFFI.abModel.currentAbTags.toList();
}
tags = [kUntagged, ...tags].toList();
final editPermission = gFFI.abModel.current.canWrite();
tagBuilder(String e) {
return AddressBookTag(
name: e,
tags: gFFI.abModel.selectedTags,
onTap: () {
if (gFFI.abModel.selectedTags.contains(e)) {
gFFI.abModel.selectedTags.remove(e);
} else {
gFFI.abModel.selectedTags.add(e);
}
},
showActionMenu: editPermission);
}
gridView(bool isPortrait) => DynamicGridView.builder(
shrinkWrap: isPortrait,
gridDelegate: SliverGridDelegateWithWrapping(),
itemCount: tags.length,
itemBuilder: (BuildContext context, int index) {
final e = tags[index];
return tagBuilder(e);
});
final maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return Obx(() => stateGlobal.isPortrait.isFalse
? gridView(false)
: LimitedBox(maxHeight: maxHeight, child: gridView(true)));
});
}
Widget _buildPeersViews() {
return Expanded(
child: Align(
alignment: Alignment.topLeft,
child: AddressBookPeersView(
menuPadding: widget.menuPadding,
)),
);
}
@protected
MenuEntryBase<String> syncMenuItem() {
final isOptFixed = isOptionFixed(syncAbOption);
return MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate('Sync with recent sessions'),
getter: () async {
return shouldSyncAb();
},
setter: (bool v) async {
gFFI.abModel.setShouldAsync(v);
},
dismissOnClicked: true,
enabled: (!isOptFixed).obs,
);
}
@protected
MenuEntryBase<String> sortMenuItem() {
final isOptFixed = isOptionFixed(sortAbTagsOption);
return MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate('Sort tags'),
getter: () async {
return shouldSortTags();
},
setter: (bool v) async {
bind.mainSetLocalOption(
key: sortAbTagsOption, value: v ? 'Y' : defaultOptionNo);
gFFI.abModel.sortTags.value = v;
},
dismissOnClicked: true,
enabled: (!isOptFixed).obs,
);
}
@protected
MenuEntryBase<String> filterMenuItem() {
final isOptFixed = isOptionFixed(filterAbTagOption);
return MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate('Filter by intersection'),
getter: () async {
return filterAbTagByIntersection();
},
setter: (bool v) async {
bind.mainSetLocalOption(
key: filterAbTagOption, value: v ? 'Y' : defaultOptionNo);
gFFI.abModel.filterByIntersection.value = v;
},
dismissOnClicked: true,
enabled: (!isOptFixed).obs,
);
}
void _showMenu(RelativeRect pos) {
final canWrite = gFFI.abModel.current.canWrite();
final items = [
if (canWrite) getEntry(translate("Add ID"), addIdToCurrentAb),
if (canWrite) getEntry(translate("Add Tag"), abAddTag),
getEntry(translate("Unselect all tags"), gFFI.abModel.unsetSelectedTags),
if (gFFI.abModel.legacyMode.value)
sortMenuItem(), // It's already sorted after pulling down
if (canWrite) syncMenuItem(),
filterMenuItem(),
if (!gFFI.abModel.legacyMode.value && canWrite)
MenuEntryDivider<String>(),
if (!gFFI.abModel.legacyMode.value && canWrite)
getEntry(translate("ab_web_console_tip"), () async {
final url = await bind.mainGetApiServer();
if (await canLaunchUrlString(url)) {
launchUrlString(url);
}
}),
];
mod_menu.showMenu(
context: context,
position: pos,
items: items
.map((e) => e.build(
context,
MenuConfig(
commonColor: CustomPopupMenuTheme.commonColor,
height: CustomPopupMenuTheme.height,
dividerHeight: CustomPopupMenuTheme.dividerHeight)))
.expand((i) => i)
.toList(),
elevation: 8,
);
}
void addIdToCurrentAb() async {
if (gFFI.abModel.isCurrentAbFull(true)) {
return;
}
var isInProgress = false;
var passwordVisible = false;
IDTextEditingController idController = IDTextEditingController(text: '');
TextEditingController aliasController = TextEditingController(text: '');
TextEditingController passwordController = TextEditingController(text: '');
TextEditingController noteController = TextEditingController(text: '');
final tags = List.of(gFFI.abModel.currentAbTags);
var selectedTag = List<dynamic>.empty(growable: true).obs;
final style = TextStyle(fontSize: 14.0);
String? errorMsg;
final isCurrentAbShared = !gFFI.abModel.current.isPersonal();
gFFI.dialogManager.show((setState, close, context) {
submit() async {
setState(() {
isInProgress = true;
errorMsg = null;
});
String id = idController.id;
if (id.isEmpty) {
// pass
} else {
if (gFFI.abModel.idContainByCurrent(id)) {
setState(() {
isInProgress = false;
errorMsg = translate('ID already exists');
});
return;
}
var password = '';
if (isCurrentAbShared) {
password = passwordController.text;
}
String? errMsg2 = await gFFI.abModel.addIdToCurrent(
id,
aliasController.text.trim(),
password,
selectedTag,
noteController.text);
if (errMsg2 != null) {
setState(() {
isInProgress = false;
errorMsg = errMsg2;
});
return;
}
// final currentPeers
}
close();
}
double marginBottom = 4;
row({required Widget label, required Widget input}) {
makeChild(bool isPortrait) => Row(
children: [
!isPortrait
? ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100),
child: label.marginOnly(right: 10))
: SizedBox.shrink(),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 200),
child: input),
),
],
).marginOnly(bottom: !isPortrait ? 8 : 0);
return Obx(() => makeChild(stateGlobal.isPortrait.isTrue));
}
return CustomAlertDialog(
title: Text(translate("Add ID")),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
row(
label: Row(
children: [
Text(
'*',
style: TextStyle(color: Colors.red, fontSize: 14),
),
Text(
'ID',
style: style,
),
],
),
input: Obx(() => TextField(
controller: idController,
inputFormatters: [IDTextInputFormatter()],
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('ID'),
errorText: errorMsg,
errorMaxLines: 5),
).workaroundFreezeLinuxMint())),
row(
label: Text(
translate('Alias'),
style: style,
),
input: Obx(() => TextField(
controller: aliasController,
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Alias'),
),
).workaroundFreezeLinuxMint()),
),
if (isCurrentAbShared)
row(
label: Text(
translate('Password'),
style: style,
),
input: Obx(
() => TextField(
controller: passwordController,
obscureText: !passwordVisible,
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Password'),
suffixIcon: IconButton(
icon: Icon(
passwordVisible
? Icons.visibility
: Icons.visibility_off,
color: MyTheme.lightTheme.primaryColor),
onPressed: () {
setState(() {
passwordVisible = !passwordVisible;
});
},
),
),
).workaroundFreezeLinuxMint(),
)),
row(
label: Text(
translate('Note'),
style: style,
),
input: Obx(
() => TextField(
controller: noteController,
maxLines: 3,
minLines: 1,
maxLength: 300,
decoration: InputDecoration(
labelText: stateGlobal.isPortrait.isFalse
? null
: translate('Note'),
),
).workaroundFreezeLinuxMint(),
)),
if (gFFI.abModel.currentAbTags.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: Text(
translate('Tags'),
style: style,
),
).marginOnly(top: 8, bottom: marginBottom),
if (gFFI.abModel.currentAbTags.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: Wrap(
children: tags
.map((e) => AddressBookTag(
name: e,
tags: selectedTag,
onTap: () {
if (selectedTag.contains(e)) {
selectedTag.remove(e);
} else {
selectedTag.add(e);
}
},
showActionMenu: false))
.toList(growable: false),
),
),
],
),
const SizedBox(
height: 4.0,
),
if (!gFFI.abModel.current.isPersonal())
Row(children: [
Icon(Icons.info, color: Colors.amber).marginOnly(right: 4),
Text(
translate('share_warning_tip'),
style: TextStyle(fontSize: 12),
)
]).marginSymmetric(vertical: 10),
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress) const LinearProgressIndicator(),
],
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
void abAddTag() async {
var field = "";
var msg = "";
var isInProgress = false;
TextEditingController controller = TextEditingController(text: field);
gFFI.dialogManager.show((setState, close, context) {
submit() async {
setState(() {
msg = "";
isInProgress = true;
});
field = controller.text.trim();
if (field.isEmpty) {
// pass
} else {
final tags = field.trim().split(RegExp(r"[\s,;\n]+"));
field = tags.join(',');
for (var t in [kUntagged, translate(kUntagged)]) {
if (tags.contains(t)) {
BotToast.showText(
contentColor: Colors.red, text: 'Tag name cannot be "$t"');
isInProgress = false;
return;
}
}
gFFI.abModel.addTags(tags);
// final currentPeers
}
close();
}
return CustomAlertDialog(
title: Text(translate("Add Tag")),
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("whitelist_sep")),
const SizedBox(
height: 8.0,
),
Row(
children: [
Expanded(
child: TextField(
maxLines: null,
decoration: InputDecoration(
errorText: msg.isEmpty ? null : translate(msg),
),
controller: controller,
autofocus: true,
).workaroundFreezeLinuxMint(),
),
],
),
const SizedBox(
height: 4.0,
),
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress) const LinearProgressIndicator(),
],
),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
}
class AddressBookTag extends StatelessWidget {
final String name;
final RxList<dynamic> tags;
final Function()? onTap;
final bool showActionMenu;
const AddressBookTag(
{Key? key,
required this.name,
required this.tags,
this.onTap,
this.showActionMenu = true})
: super(key: key);
@override
Widget build(BuildContext context) {
var pos = RelativeRect.fill;
void setPosition(TapDownDetails e) {
final x = e.globalPosition.dx;
final y = e.globalPosition.dy;
pos = RelativeRect.fromLTRB(x, y, x, y);
}
const double radius = 8;
final isUnTagged = name == kUntagged;
final showAction = showActionMenu && !isUnTagged;
return GestureDetector(
onTap: onTap,
onTapDown: showAction ? setPosition : null,
onSecondaryTapDown: showAction ? setPosition : null,
onSecondaryTap: showAction ? () => _showMenu(context, pos) : null,
onLongPress: showAction ? () => _showMenu(context, pos) : null,
child: Obx(() => Container(
decoration: BoxDecoration(
color: tags.contains(name)
? gFFI.abModel.getCurrentAbTagColor(name)
: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(4)),
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0),
child: IntrinsicWidth(
child: Row(
children: [
if (!isUnTagged)
Container(
width: radius,
height: radius,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: tags.contains(name)
? Colors.white
: gFFI.abModel.getCurrentAbTagColor(name)),
).marginOnly(right: radius / 2),
Expanded(
child: Text(isUnTagged ? translate(name) : name,
style: TextStyle(
overflow: TextOverflow.ellipsis,
color: tags.contains(name) ? Colors.white : null)),
),
],
),
),
)),
);
}
void _showMenu(BuildContext context, RelativeRect pos) {
final items = [
getEntry(translate("Rename"), () {
renameDialog(
oldName: name,
validator: (String? newName) {
if (newName == null || newName.isEmpty) {
return translate('Can not be empty');
}
if (newName != name &&
gFFI.abModel.currentAbTags.contains(newName)) {
return translate('Already exists');
}
return null;
},
onSubmit: (String newName) {
if (name != newName) {
gFFI.abModel.renameTag(name, newName);
}
Future.delayed(Duration.zero, () => Get.back());
},
onCancel: () {
Future.delayed(Duration.zero, () => Get.back());
});
}),
getEntry(translate(translate('Change Color')), () async {
final model = gFFI.abModel;
Color oldColor = model.getCurrentAbTagColor(name);
Color newColor = await showColorPickerDialog(
context,
oldColor,
pickersEnabled: {
ColorPickerType.accent: false,
ColorPickerType.wheel: true,
},
pickerTypeLabels: {
ColorPickerType.primary: translate("Primary Color"),
ColorPickerType.wheel: translate("HSV Color"),
},
actionButtons: ColorPickerActionButtons(
dialogOkButtonLabel: translate("OK"),
dialogCancelButtonLabel: translate("Cancel")),
showColorCode: true,
);
if (oldColor != newColor) {
model.setTagColor(name, newColor);
}
}),
getEntry(translate("Delete"), () {
gFFI.abModel.deleteTag(name);
Future.delayed(Duration.zero, () => Get.back());
}),
];
mod_menu.showMenu(
context: context,
position: pos,
items: items
.map((e) => e.build(
context,
MenuConfig(
commonColor: CustomPopupMenuTheme.commonColor,
height: CustomPopupMenuTheme.height,
dividerHeight: CustomPopupMenuTheme.dividerHeight)))
.expand((i) => i)
.toList(),
elevation: 8,
);
}
}
MenuEntryButton<String> getEntry(String title, VoidCallback proc) {
return MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
title,
style: style,
),
proc: proc,
dismissOnClicked: true,
);
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class AnimatedRotationWidget extends StatefulWidget {
final VoidCallback onPressed;
final ValueChanged<bool>? onHover;
final Widget child;
final RxBool? spinning;
const AnimatedRotationWidget(
{super.key,
required this.onPressed,
required this.child,
this.spinning,
this.onHover});
@override
State<AnimatedRotationWidget> createState() => AnimatedRotationWidgetState();
}
class AnimatedRotationWidgetState extends State<AnimatedRotationWidget> {
double turns = 0.0;
@override
void initState() {
super.initState();
widget.spinning?.listen((v) {
if (v && mounted) {
setState(() {
turns += 1;
});
}
});
}
@override
Widget build(BuildContext context) {
return AnimatedRotation(
turns: turns,
duration: const Duration(milliseconds: 200),
onEnd: () {
if (widget.spinning?.value == true && mounted) {
setState(() => turns += 1.0);
}
},
child: InkWell(
onTap: () {
if (mounted) setState(() => turns += 1.0);
widget.onPressed();
},
onHover: widget.onHover,
child: widget.child));
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
const _kSystemSound = 'System Sound';
typedef AudioINputSetDevice = void Function(String device);
typedef AudioInputBuilder = Widget Function(
List<String> devices, String currentDevice, AudioINputSetDevice setDevice);
class AudioInput extends StatelessWidget {
final AudioInputBuilder builder;
final bool isCm;
final bool isVoiceCall;
const AudioInput(
{Key? key,
required this.builder,
required this.isCm,
required this.isVoiceCall})
: super(key: key);
static String getDefault() {
if (bind.mainAudioSupportLoopback()) return translate(_kSystemSound);
return '';
}
static Future<String> getAudioInput(bool isCm, bool isVoiceCall) {
if (isVoiceCall) {
return bind.getVoiceCallInputDevice(isCm: isCm);
} else {
return bind.mainGetOption(key: 'audio-input');
}
}
static Future<String> getValue(bool isCm, bool isVoiceCall) async {
String device = await getAudioInput(isCm, isVoiceCall);
if (device.isNotEmpty) {
return device;
} else {
return getDefault();
}
}
static Future<void> setDevice(
String device, bool isCm, bool isVoiceCall) async {
if (device == getDefault()) device = '';
if (isVoiceCall) {
await bind.setVoiceCallInputDevice(isCm: isCm, device: device);
} else {
await bind.mainSetOption(key: 'audio-input', value: device);
}
}
static Future<Map<String, Object>> getDevicesInfo(
bool isCm, bool isVoiceCall) async {
List<String> devices = (await bind.mainGetSoundInputs()).toList();
if (bind.mainAudioSupportLoopback()) {
devices.insert(0, translate(_kSystemSound));
}
String current = await getValue(isCm, isVoiceCall);
return {'devices': devices, 'current': current};
}
@override
Widget build(BuildContext context) {
return futureBuilder(
future: getDevicesInfo(isCm, isVoiceCall),
hasData: (data) {
String currentDevice = data['current'];
List<String> devices = data['devices'] as List<String>;
if (devices.isEmpty) {
return const Offstage();
}
return builder(devices, currentDevice, (devices) {
setDevice(devices, isCm, isVoiceCall);
});
},
);
}
}
@@ -0,0 +1,257 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import '../../../models/platform_model.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/peer_card.dart';
class AllPeersLoader {
List<Peer> peers = [];
bool _isPeersLoading = false;
bool _isPeersLoaded = false;
final String _listenerKey = 'AllPeersLoader';
late void Function(VoidCallback) setState;
bool get needLoad => !_isPeersLoaded && !_isPeersLoading;
bool get isPeersLoaded => _isPeersLoaded;
AllPeersLoader();
void init(void Function(VoidCallback) setState) {
this.setState = setState;
gFFI.recentPeersModel.addListener(_mergeAllPeers);
gFFI.lanPeersModel.addListener(_mergeAllPeers);
gFFI.abModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
gFFI.groupModel.addPeerUpdateListener(_listenerKey, _mergeAllPeers);
}
void clear() {
gFFI.recentPeersModel.removeListener(_mergeAllPeers);
gFFI.lanPeersModel.removeListener(_mergeAllPeers);
gFFI.abModel.removePeerUpdateListener(_listenerKey);
gFFI.groupModel.removePeerUpdateListener(_listenerKey);
}
Future<void> getAllPeers() async {
if (!needLoad) {
return;
}
_isPeersLoading = true;
if (gFFI.recentPeersModel.peers.isEmpty) {
bind.mainLoadRecentPeers();
}
if (gFFI.lanPeersModel.peers.isEmpty) {
bind.mainLoadLanPeers();
}
// No need to care about peers from abModel, and group model.
// Because they will pull data in `refreshCurrentUser()` on startup.
final startTime = DateTime.now();
_mergeAllPeers();
final diffTime = DateTime.now().difference(startTime).inMilliseconds;
if (diffTime < 100) {
await Future.delayed(Duration(milliseconds: diffTime));
}
}
void _mergeAllPeers() {
Map<String, dynamic> combinedPeers = {};
for (var p in gFFI.abModel.allPeers()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
}
for (var p in gFFI.groupModel.peers.map((e) => Peer.copy(e)).toList()) {
if (!combinedPeers.containsKey(p.id)) {
combinedPeers[p.id] = p.toJson();
}
}
List<Peer> parsedPeers = [];
for (var peer in combinedPeers.values) {
parsedPeers.add(Peer.fromJson(peer));
}
Set<String> peerIds = combinedPeers.keys.toSet();
for (final peer in gFFI.lanPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final peer in gFFI.recentPeersModel.peers) {
if (!peerIds.contains(peer.id)) {
parsedPeers.add(peer);
peerIds.add(peer.id);
}
}
for (final id in gFFI.recentPeersModel.restPeerIds) {
if (!peerIds.contains(id)) {
parsedPeers.add(Peer.fromJson({'id': id}));
peerIds.add(id);
}
}
peers = parsedPeers;
setState(() {
_isPeersLoading = false;
_isPeersLoaded = true;
});
}
}
class AutocompletePeerTile extends StatefulWidget {
final VoidCallback onSelect;
final Peer peer;
const AutocompletePeerTile({
Key? key,
required this.onSelect,
required this.peer,
}) : super(key: key);
@override
AutocompletePeerTileState createState() => AutocompletePeerTileState();
}
class AutocompletePeerTileState extends State<AutocompletePeerTile> {
List _frontN<T>(List list, int n) {
if (list.length <= n) {
return list;
} else {
return list.sublist(0, n);
}
}
@override
Widget build(BuildContext context) {
final double tileRadius = 5;
final name =
'${widget.peer.username}${widget.peer.username.isNotEmpty && widget.peer.hostname.isNotEmpty ? '@' : ''}${widget.peer.hostname}';
final greyStyle = TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6));
final child = GestureDetector(
onTap: () => widget.onSelect(),
child: Padding(
padding: EdgeInsets.only(left: 5, right: 5),
child: Container(
height: 42,
margin: EdgeInsets.only(bottom: 5),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Container(
decoration: BoxDecoration(
color: str2color(
'${widget.peer.id}${widget.peer.platform}', 0x7f),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(tileRadius),
bottomLeft: Radius.circular(tileRadius),
),
),
alignment: Alignment.center,
width: 42,
height: null,
child: Padding(
padding: EdgeInsets.all(6),
child: getPlatformImage(widget.peer.platform,
size: 30))),
Expanded(
child: Container(
padding: EdgeInsets.only(left: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.only(
topRight: Radius.circular(tileRadius),
bottomRight: Radius.circular(tileRadius),
),
),
child: Row(
children: [
Expanded(
child: Container(
margin: EdgeInsets.only(top: 2),
child: Container(
margin: EdgeInsets.only(top: 2),
child: Column(
children: [
Container(
margin:
EdgeInsets.only(top: 2),
child: Row(children: [
getOnline(
8, widget.peer.online),
Expanded(
child: Text(
widget.peer.alias.isEmpty
? formatID(
widget.peer.id)
: widget.peer.alias,
overflow:
TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleSmall,
)),
widget.peer.alias.isNotEmpty
? Padding(
padding:
const EdgeInsets
.only(
left: 5,
right: 5),
child: Text(
"(${widget.peer.id})",
style: greyStyle,
overflow:
TextOverflow
.ellipsis,
))
: Container(),
])),
Align(
alignment: Alignment.centerLeft,
child: Text(
name,
style: greyStyle,
textAlign: TextAlign.start,
overflow:
TextOverflow.ellipsis,
),
),
],
)))),
],
)),
)
],
))));
final colors = _frontN(widget.peer.tags, 25)
.map((e) => gFFI.abModel.getCurrentAbTagColor(e))
.toList();
return Tooltip(
message: !(isDesktop || isWebDesktop)
? ''
: widget.peer.tags.isNotEmpty
? '${translate('Tags')}: ${widget.peer.tags.join(', ')}'
: '',
child: Stack(children: [
child,
if (colors.isNotEmpty)
Positioned(
top: 5,
right: 10,
child: CustomPaint(
painter: TagPainter(radius: 3, colors: colors),
),
)
]),
);
}
}
+180
View File
@@ -0,0 +1,180 @@
import 'package:dash_chat_2/dash_chat_2.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import '../../mobile/pages/home_page.dart';
enum ChatPageType {
mobileMain,
desktopCM,
}
class ChatPage extends StatelessWidget implements PageShape {
late final ChatModel chatModel;
final ChatPageType? type;
ChatPage({ChatModel? chatModel, this.type}) {
this.chatModel = chatModel ?? gFFI.chatModel;
}
@override
final title = translate("Chat");
@override
final icon = unreadTopRightBuilder(gFFI.chatModel.mobileUnreadSum);
@override
final appBarActions = [
PopupMenuButton<MessageKey>(
tooltip: "",
icon: unreadTopRightBuilder(gFFI.chatModel.mobileUnreadSum,
icon: Icon(Icons.group)),
itemBuilder: (context) {
// only mobile need [appBarActions], just bind gFFI.chatModel
final chatModel = gFFI.chatModel;
return chatModel.messages.entries.map((entry) {
final key = entry.key;
final user = entry.value.chatUser;
final client = gFFI.serverModel.clients
.firstWhereOrNull((e) => e.id == key.connId);
final connected =
gFFI.serverModel.clients.any((e) => e.id == key.connId);
return PopupMenuItem<MessageKey>(
child: Row(
children: [
Icon(
key.isOut
? Icons.call_made_rounded
: Icons.call_received_rounded,
color: MyTheme.accent)
.marginOnly(right: 6),
Text("${user.firstName} ${user.id}"),
if (connected)
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.fromARGB(255, 46, 205, 139)),
).marginSymmetric(horizontal: 2),
if (client != null)
unreadMessageCountBuilder(client.unreadChatMessageCount)
.marginOnly(left: 4)
],
),
value: key,
);
}).toList();
},
onSelected: (key) {
gFFI.chatModel.changeCurrentKey(key);
})
];
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: chatModel,
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: Consumer<ChatModel>(
builder: (context, chatModel, child) {
final readOnly = type == ChatPageType.mobileMain &&
(chatModel.currentKey.connId == ChatModel.clientModeID ||
gFFI.serverModel.clients.every((e) =>
e.id != chatModel.currentKey.connId ||
chatModel.currentUser == null)) ||
type == ChatPageType.desktopCM &&
gFFI.serverModel.clients
.firstWhereOrNull(
(e) => e.id == chatModel.currentKey.connId)
?.disconnected ==
true;
return Stack(
children: [
LayoutBuilder(builder: (context, constraints) {
final chat = DashChat(
onSend: chatModel.send,
currentUser: chatModel.me,
messages: chatModel
.messages[chatModel.currentKey]?.chatMessages ??
[],
readOnly: readOnly,
inputOptions: InputOptions(
focusNode: chatModel.inputNode,
textController: chatModel.textController,
inputTextStyle: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.titleLarge?.color),
inputDecoration: InputDecoration(
isDense: true,
hintText: translate('Write a message'),
filled: true,
fillColor: Theme.of(context).colorScheme.background,
contentPadding: EdgeInsets.all(10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10.0),
borderSide: const BorderSide(
width: 1,
style: BorderStyle.solid,
),
),
),
sendButtonBuilder: defaultSendButton(
padding:
EdgeInsets.symmetric(horizontal: 6, vertical: 0),
color: MyTheme.accent,
icon: Icons.send_rounded,
),
),
messageOptions: MessageOptions(
showOtherUsersAvatar: false,
showOtherUsersName: false,
textColor: Colors.white,
maxWidth: constraints.maxWidth * 0.7,
messageTextBuilder: (message, _, __) {
final isOwnMessage = message.user.id.isBlank!;
return Column(
crossAxisAlignment: isOwnMessage
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: <Widget>[
Text(message.text,
style: TextStyle(color: Colors.white)),
Text(
"${message.createdAt.hour}:${message.createdAt.minute.toString().padLeft(2, '0')}",
style: TextStyle(
color: Colors.white,
fontSize: 8,
),
).marginOnly(top: 3),
],
);
},
messageDecorationBuilder:
(message, previousMessage, nextMessage) {
final isOwnMessage = message.user.id.isBlank!;
return defaultMessageDecoration(
color:
isOwnMessage ? MyTheme.accent : Colors.blueGrey,
borderTopLeft: 8,
borderTopRight: 8,
borderBottomRight: isOwnMessage ? 2 : 8,
borderBottomLeft: isOwnMessage ? 8 : 2,
);
},
),
).workaroundFreezeLinuxMint();
return SelectionArea(child: chat);
}),
],
).paddingOnly(bottom: 8);
},
),
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
Widget getConnectionPageTitle(BuildContext context, bool isWeb) {
return Row(
children: [
Expanded(
child: Row(
children: [
AutoSizeText(
translate('Control Remote Desktop'),
maxLines: 1,
style: Theme.of(context)
.textTheme
.titleLarge
?.merge(TextStyle(height: 1)),
).marginOnly(right: 4),
Tooltip(
waitDuration: Duration(milliseconds: 300),
message: translate(isWeb ? "web_id_input_tip" : "id_input_tip"),
child: Icon(
Icons.help_outline_outlined,
size: 16,
color: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.5),
),
),
],
)),
],
);
}
@@ -0,0 +1,129 @@
// https://github.com/rodrigobastosv/fancy_password_field
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:get/get.dart';
import 'package:password_strength/password_strength.dart';
abstract class ValidationRule {
String get name;
bool validate(String value);
}
class UppercaseValidationRule extends ValidationRule {
@override
String get name => translate('uppercase');
@override
bool validate(String value) {
return value.runes.any((int rune) {
var character = String.fromCharCode(rune);
return character.toUpperCase() == character &&
character.toLowerCase() != character;
});
}
}
class LowercaseValidationRule extends ValidationRule {
@override
String get name => translate('lowercase');
@override
bool validate(String value) {
return value.runes.any((int rune) {
var character = String.fromCharCode(rune);
return character.toLowerCase() == character &&
character.toUpperCase() != character;
});
}
}
class DigitValidationRule extends ValidationRule {
@override
String get name => translate('digit');
@override
bool validate(String value) {
return value.contains(RegExp(r'[0-9]'));
}
}
class SpecialCharacterValidationRule extends ValidationRule {
@override
String get name => translate('special character');
@override
bool validate(String value) {
return value.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
}
}
class MinCharactersValidationRule extends ValidationRule {
final int _numberOfCharacters;
MinCharactersValidationRule(this._numberOfCharacters);
@override
String get name => translate('length>=$_numberOfCharacters');
@override
bool validate(String value) {
return value.length >= _numberOfCharacters;
}
}
class PasswordStrengthIndicator extends StatelessWidget {
final RxString password;
final double weakMedium = 0.33;
final double mediumStrong = 0.67;
const PasswordStrengthIndicator({Key? key, required this.password})
: super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
var strength = estimatePasswordStrength(password.value);
return Row(
children: [
Expanded(
child: _indicator(
password.isEmpty ? Colors.grey : _getColor(strength))),
Expanded(
child: _indicator(password.isEmpty || strength < weakMedium
? Colors.grey
: _getColor(strength))),
Expanded(
child: _indicator(password.isEmpty || strength < mediumStrong
? Colors.grey
: _getColor(strength))),
Text(password.isEmpty ? '' : translate(_getLabel(strength)))
.marginOnly(left: password.isEmpty ? 0 : 8),
],
);
});
}
Widget _indicator(Color color) {
return Container(
height: 8,
color: color,
);
}
String _getLabel(double strength) {
if (strength < weakMedium) {
return 'Weak';
} else if (strength < mediumStrong) {
return 'Medium';
} else {
return 'Strong';
}
}
Color _getColor(double strength) {
if (strength < weakMedium) {
return Colors.yellow;
} else if (strength < mediumStrong) {
return Colors.blue;
} else {
return Colors.green;
}
}
}
@@ -0,0 +1,156 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/utils/scale.dart';
import 'package:flutter_hbb/common.dart';
/// Base class providing shared custom scale control logic for both mobile and desktop widgets.
/// Implementations must provide [ffi] and [onScaleChanged] getters.
abstract class CustomScaleControls<T extends StatefulWidget> extends State<T> {
/// FFI instance for session interaction
FFI get ffi;
/// Callback invoked when scale value changes
ValueChanged<int>? get onScaleChanged;
late int _scaleValue;
late final Debouncer<int> _debouncerScale;
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
double _scalePos = 0.0;
int get scaleValue => _scaleValue;
double get scalePos => _scalePos;
int mapPosToPercent(double p) => _mapPosToPercent(p);
static const int minPercent = kScaleCustomMinPercent;
static const int pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
static const int maxPercent = kScaleCustomMaxPercent;
static const double pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
static const double detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
// Clamp helper for local use
int _clampScale(int v) => clampCustomScalePercent(v);
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
int _mapPosToPercent(double p) {
if (p <= 0.0) return minPercent;
if (p >= 1.0) return maxPercent;
if (p <= pivotPos) {
final q = p / pivotPos; // 0..1
final v = minPercent + q * (pivotPercent - minPercent);
return _clampScale(v.round());
} else {
final q = (p - pivotPos) / (1.0 - pivotPos); // 0..1
final v = pivotPercent + q * (maxPercent - pivotPercent);
return _clampScale(v.round());
}
}
// Map percent [5,1000] → normalized position [0,1]
double _mapPercentToPos(int percent) {
final p = _clampScale(percent);
if (p <= pivotPercent) {
final q = (p - minPercent) / (pivotPercent - minPercent);
return q * pivotPos;
} else {
final q = (p - pivotPercent) / (maxPercent - pivotPercent);
return pivotPos + q * (1.0 - pivotPos);
}
}
// Snap normalized position to the pivot when close to it
double _snapNormalizedPos(double p) {
if ((p - pivotPos).abs() <= detentEpsilon) return pivotPos;
if (p < 0.0) return 0.0;
if (p > 1.0) return 1.0;
return p;
}
@override
void initState() {
super.initState();
_scaleValue = 100;
_debouncerScale = Debouncer<int>(
kDebounceCustomScaleDuration,
onChanged: (v) async {
await _applyScale(v);
},
initialValue: _scaleValue,
);
WidgetsBinding.instance.addPostFrameCallback((_) async {
try {
final v = await getSessionCustomScalePercent(ffi.sessionId);
if (mounted) {
setState(() {
_scaleValue = v;
_scalePos = _mapPercentToPos(v);
});
}
} catch (e, st) {
debugPrint('[CustomScale] Failed to get initial value: $e');
debugPrintStack(stackTrace: st);
}
});
}
Future<void> _applyScale(int v) async {
v = clampCustomScalePercent(v);
setState(() {
_scaleValue = v;
});
try {
await bind.sessionSetFlutterOption(
sessionId: ffi.sessionId,
k: kCustomScalePercentKey,
v: v.toString());
final curStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
if (curStyle != kRemoteViewStyleCustom) {
await bind.sessionSetViewStyle(
sessionId: ffi.sessionId, value: kRemoteViewStyleCustom);
}
await ffi.canvasModel.updateViewStyle();
if (isMobile) {
HapticFeedback.selectionClick();
}
onScaleChanged?.call(v);
} catch (e, st) {
debugPrint('[CustomScale] Apply failed: $e');
debugPrintStack(stackTrace: st);
}
}
void nudgeScale(int delta) {
final next = _clampScale(_scaleValue + delta);
setState(() {
_scaleValue = next;
_scalePos = _mapPercentToPos(next);
});
onScaleChanged?.call(next);
_debouncerScale.value = next;
}
@override
void dispose() {
_debouncerScale.cancel();
super.dispose();
}
void onSliderChanged(double v) {
final snapped = _snapNormalizedPos(v);
final next = _mapPosToPercent(snapped);
if (next != _scaleValue || snapped != _scalePos) {
setState(() {
_scalePos = snapped;
_scaleValue = next;
});
onScaleChanged?.call(next);
_debouncerScale.value = next;
}
}
}
File diff suppressed because it is too large Load Diff
+797
View File
@@ -0,0 +1,797 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
enum GestureState {
none,
oneFingerPan,
twoFingerScale,
threeFingerVerticalDrag
}
class CustomTouchGestureRecognizer extends ScaleGestureRecognizer {
CustomTouchGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(
debugOwner: debugOwner,
supportedDevices: supportedDevices,
) {
_init();
}
// oneFingerPan
GestureDragStartCallback? onOneFingerPanStart;
GestureDragUpdateCallback? onOneFingerPanUpdate;
GestureDragEndCallback? onOneFingerPanEnd;
GestureDragCancelCallback? onOneFingerPanCancel;
// twoFingerScale : scale + pan event
GestureScaleStartCallback? onTwoFingerScaleStart;
GestureScaleUpdateCallback? onTwoFingerScaleUpdate;
GestureScaleEndCallback? onTwoFingerScaleEnd;
// threeFingerVerticalDrag
GestureDragStartCallback? onThreeFingerVerticalDragStart;
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate;
GestureDragEndCallback? onThreeFingerVerticalDragEnd;
var _currentState = GestureState.none;
Timer? _debounceTimer;
void _init() {
debugPrint("CustomTouchGestureRecognizer init");
// onStart = (d) {};
onUpdate = (d) {
_debounceTimer?.cancel();
if (d.pointerCount == 1 && _currentState != GestureState.oneFingerPan) {
onOneFingerStartDebounce(d);
} else if (d.pointerCount == 2 &&
_currentState != GestureState.twoFingerScale) {
onTwoFingerStartDebounce(d);
} else if (d.pointerCount == 3 &&
_currentState != GestureState.threeFingerVerticalDrag) {
_currentState = GestureState.threeFingerVerticalDrag;
if (onThreeFingerVerticalDragStart != null) {
onThreeFingerVerticalDragStart!(
DragStartDetails(globalPosition: d.localFocalPoint));
}
debugPrint("start threeFingerScale");
}
if (_currentState != GestureState.none) {
switch (_currentState) {
case GestureState.oneFingerPan:
if (onOneFingerPanUpdate != null) {
onOneFingerPanUpdate!(_getDragUpdateDetails(d));
}
break;
case GestureState.twoFingerScale:
if (onTwoFingerScaleUpdate != null) {
onTwoFingerScaleUpdate!(d);
}
break;
case GestureState.threeFingerVerticalDrag:
if (onThreeFingerVerticalDragUpdate != null) {
onThreeFingerVerticalDragUpdate!(_getDragUpdateDetails(d));
}
break;
default:
break;
}
return;
}
};
onEnd = (d) {
debugPrint("ScaleGestureRecognizer onEnd");
_debounceTimer?.cancel();
// end
switch (_currentState) {
case GestureState.oneFingerPan:
debugPrint("OneFingerState.pan onEnd");
if (onOneFingerPanEnd != null) {
onOneFingerPanEnd!(_getDragEndDetails(d));
}
break;
case GestureState.twoFingerScale:
debugPrint("TwoFingerState.scale onEnd");
if (onTwoFingerScaleEnd != null) {
onTwoFingerScaleEnd!(d);
}
if (isSpecialHoldDragActive) {
// If we are in special drag mode, we need to reset the state.
// Otherwise, the next `onTwoFingerScaleUpdate()` will handle a wrong `focalPoint`.
_currentState = GestureState.none;
return;
}
break;
case GestureState.threeFingerVerticalDrag:
debugPrint("ThreeFingerState.vertical onEnd");
if (onThreeFingerVerticalDragEnd != null) {
onThreeFingerVerticalDragEnd!(_getDragEndDetails(d));
}
break;
default:
break;
}
_debounceTimer = Timer(Duration(milliseconds: 200), () {
_currentState = GestureState.none;
});
};
}
// FIXME: This debounce logic is not working properly.
// If we move our finger very fast, we won't be able to detect the "oneFingerPan" event sometimes.
void onOneFingerStartDebounce(ScaleUpdateDetails d) {
start(ScaleUpdateDetails d) {
_currentState = GestureState.oneFingerPan;
if (onOneFingerPanStart != null) {
onOneFingerPanStart!(DragStartDetails(
localPosition: d.localFocalPoint, globalPosition: d.focalPoint));
}
}
if (_currentState != GestureState.none) {
_debounceTimer = Timer(Duration(milliseconds: 200), () {
start(d);
debugPrint("debounce start oneFingerPan");
});
} else {
start(d);
debugPrint("start oneFingerPan");
}
}
void onTwoFingerStartDebounce(ScaleUpdateDetails d) {
start(ScaleUpdateDetails d) {
_currentState = GestureState.twoFingerScale;
if (onTwoFingerScaleStart != null) {
onTwoFingerScaleStart!(ScaleStartDetails(
localFocalPoint: d.localFocalPoint, focalPoint: d.focalPoint));
}
}
if (_currentState == GestureState.threeFingerVerticalDrag) {
_debounceTimer = Timer(Duration(milliseconds: 200), () {
start(d);
debugPrint("debounce start twoFingerScale");
});
} else {
start(d);
debugPrint("start twoFingerScale");
}
}
DragUpdateDetails _getDragUpdateDetails(ScaleUpdateDetails d) =>
DragUpdateDetails(
globalPosition: d.focalPoint,
localPosition: d.localFocalPoint,
delta: d.focalPointDelta);
DragEndDetails _getDragEndDetails(ScaleEndDetails d) =>
DragEndDetails(velocity: d.velocity);
@override
void rejectGesture(int pointer) {
super.rejectGesture(pointer);
switch (_currentState) {
case GestureState.oneFingerPan:
if (onOneFingerPanCancel != null) {
onOneFingerPanCancel!();
}
break;
case GestureState.twoFingerScale:
// Reset scale state if needed, currently self-contained
break;
case GestureState.threeFingerVerticalDrag:
// Reset drag state if needed, currently self-contained
break;
default:
break;
}
_currentState = GestureState.none;
}
}
class HoldTapMoveGestureRecognizer extends GestureRecognizer {
HoldTapMoveGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(
debugOwner: debugOwner,
supportedDevices: supportedDevices,
);
GestureDragStartCallback? onHoldDragStart;
GestureDragUpdateCallback? onHoldDragUpdate;
GestureDragDownCallback? onHoldDragDown;
GestureDragCancelCallback? onHoldDragCancel;
GestureDragEndCallback? onHoldDragEnd;
bool _isStart = false;
Timer? _firstTapUpTimer;
Timer? _secondTapDownTimer;
_TapTracker? _firstTap;
_TapTracker? _secondTap;
PointerDownEvent? _lastPointerDownEvent;
final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
@override
bool isPointerAllowed(PointerDownEvent event) {
if (_firstTap == null) {
switch (event.buttons) {
case kPrimaryButton:
if (onHoldDragStart == null &&
onHoldDragUpdate == null &&
onHoldDragCancel == null &&
onHoldDragEnd == null) {
return false;
}
break;
default:
return false;
}
}
return super.isPointerAllowed(event);
}
@override
void addAllowedPointer(PointerDownEvent event) {
if (_firstTap != null) {
if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
// Ignore out-of-bounds second taps.
return;
} else if (!_firstTap!.hasElapsedMinTime() ||
!_firstTap!.hasSameButton(event)) {
// Restart when the second tap is too close to the first (touch screens
// often detect touches intermittently), or when buttons mismatch.
_reset();
return _trackTap(event);
} else if (onHoldDragDown != null) {
invokeCallback<void>(
'onHoldDragDown',
() => onHoldDragDown!(DragDownDetails(
globalPosition: event.position,
localPosition: event.localPosition)));
}
}
_trackTap(event);
}
void _trackTap(PointerDownEvent event) {
_stopFirstTapUpTimer();
_stopSecondTapDownTimer();
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
gestureSettings: gestureSettings,
);
_trackers[event.pointer] = tracker;
_lastPointerDownEvent = event;
tracker.startTrackingPointer(_handleEvent, event.transform);
}
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer]!;
if (event is PointerUpEvent) {
if (_firstTap == null && _secondTap == null) {
_registerFirstTap(tracker);
} else if (_secondTap != null) {
if (event.pointer == _secondTap!.pointer) {
if (onHoldDragEnd != null) {
onHoldDragEnd!(DragEndDetails());
_secondTap = null;
_isStart = false;
}
}
} else {
_reject(tracker);
}
} else if (event is PointerDownEvent) {
if (_firstTap != null && _secondTap == null) {
_registerSecondTap(tracker);
}
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
if (_firstTap != null && _firstTap!.pointer == event.pointer) {
// first tap move
_reject(tracker);
} else if (_secondTap != null && _secondTap!.pointer == event.pointer) {
// debugPrint("_secondTap move");
// second tap move
if (!_isStart) {
_resolve();
}
if (onHoldDragUpdate != null) {
onHoldDragUpdate!(DragUpdateDetails(
globalPosition: event.position,
localPosition: event.localPosition,
delta: event.delta));
}
}
}
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
@override
void acceptGesture(int pointer) {}
@override
void rejectGesture(int pointer) {
_TapTracker? tracker = _trackers[pointer];
// If tracker isn't in the list, check if this is the first tap tracker
if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) {
tracker = _firstTap;
}
// If tracker is still null, we rejected ourselves already
if (tracker != null) {
_reject(tracker);
}
}
void _resolve() {
_stopSecondTapDownTimer();
_firstTap?.entry.resolve(GestureDisposition.accepted);
_secondTap?.entry.resolve(GestureDisposition.accepted);
_isStart = true;
// TODO start details
if (onHoldDragStart != null) {
onHoldDragStart!(DragStartDetails(
kind: _lastPointerDownEvent?.kind,
));
}
}
void _reject(_TapTracker tracker) {
try {
_checkCancel();
_isStart = false;
_trackers.remove(tracker.pointer);
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
_reset();
} catch (e) {
debugPrint("Failed to _reject:$e");
}
}
@override
void dispose() {
_reset();
super.dispose();
}
void _reset() {
_isStart = false;
// debugPrint("reset");
_stopFirstTapUpTimer();
_stopSecondTapDownTimer();
if (_firstTap != null) {
if (_trackers.isNotEmpty) {
_checkCancel();
}
// Note, order is important below in order for the resolve -> reject logic
// to work properly.
final _TapTracker tracker = _firstTap!;
_firstTap = null;
_reject(tracker);
GestureBinding.instance.gestureArena.release(tracker.pointer);
if (_secondTap != null) {
final _TapTracker tracker = _secondTap!;
_secondTap = null;
_reject(tracker);
GestureBinding.instance.gestureArena.release(tracker.pointer);
}
}
_firstTap = null;
_secondTap = null;
_clearTrackers();
}
void _registerFirstTap(_TapTracker tracker) {
_startFirstTapUpTimer();
GestureBinding.instance.gestureArena.hold(tracker.pointer);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
_freezeTracker(tracker);
_trackers.remove(tracker.pointer);
_firstTap = tracker;
}
void _registerSecondTap(_TapTracker tracker) {
if (_firstTap != null) {
_stopFirstTapUpTimer();
_freezeTracker(_firstTap!);
_firstTap = null;
}
_startSecondTapDownTimer();
GestureBinding.instance.gestureArena.hold(tracker.pointer);
_secondTap = tracker;
// TODO
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
assert(_trackers.isEmpty);
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _startFirstTapUpTimer() {
_firstTapUpTimer ??= Timer(kDoubleTapTimeout, _reset);
}
void _startSecondTapDownTimer() {
_secondTapDownTimer ??= Timer(kDoubleTapTimeout, _resolve);
}
void _stopFirstTapUpTimer() {
if (_firstTapUpTimer != null) {
_firstTapUpTimer!.cancel();
_firstTapUpTimer = null;
}
}
void _stopSecondTapDownTimer() {
if (_secondTapDownTimer != null) {
_secondTapDownTimer!.cancel();
_secondTapDownTimer = null;
}
}
void _checkCancel() {
if (onHoldDragCancel != null) {
invokeCallback<void>('onHoldDragCancel', onHoldDragCancel!);
}
}
@override
String get debugDescription => 'double tap';
}
class DoubleFinerTapGestureRecognizer extends GestureRecognizer {
DoubleFinerTapGestureRecognizer({
Object? debugOwner,
Set<PointerDeviceKind>? supportedDevices,
}) : super(
debugOwner: debugOwner,
supportedDevices: supportedDevices,
);
GestureTapDownCallback? onDoubleFinerTapDown;
GestureTapDownCallback? onDoubleFinerTap;
GestureTapCancelCallback? onDoubleFinerTapCancel;
Timer? _firstTapTimer;
_TapTracker? _firstTap;
PointerDownEvent? _lastPointerDownEvent;
var _isStart = false;
final Set<int> _upTap = {};
final Map<int, _TapTracker> _trackers = <int, _TapTracker>{};
@override
bool isPointerAllowed(PointerDownEvent event) {
if (_firstTap == null) {
switch (event.buttons) {
case kPrimaryButton:
if (onDoubleFinerTapDown == null &&
onDoubleFinerTap == null &&
onDoubleFinerTapCancel == null) {
return false;
}
break;
default:
return false;
}
}
return super.isPointerAllowed(event);
}
@override
void addAllowedPointer(PointerDownEvent event) {
debugPrint("addAllowedPointer");
if (_isStart) {
// second
if (onDoubleFinerTapDown != null) {
final TapDownDetails details = TapDownDetails(
globalPosition: event.position,
localPosition: event.localPosition,
kind: getKindForPointer(event.pointer),
);
invokeCallback<void>(
'onDoubleFinerTapDown', () => onDoubleFinerTapDown!(details));
}
} else {
// first tap
_isStart = true;
_lastPointerDownEvent = event;
_startFirstTapDownTimer();
}
_trackTap(event);
}
void _trackTap(PointerDownEvent event) {
final _TapTracker tracker = _TapTracker(
event: event,
entry: GestureBinding.instance.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime,
gestureSettings: gestureSettings,
);
_trackers[event.pointer] = tracker;
// debugPrint("_trackers:$_trackers");
tracker.startTrackingPointer(_handleEvent, event.transform);
_registerTap(tracker);
}
void _handleEvent(PointerEvent event) {
final _TapTracker tracker = _trackers[event.pointer]!;
if (event is PointerUpEvent) {
debugPrint("PointerUpEvent");
_upTap.add(tracker.pointer);
} else if (event is PointerMoveEvent) {
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop)) {
_reject(tracker);
}
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
@override
void acceptGesture(int pointer) {}
@override
void rejectGesture(int pointer) {
_TapTracker? tracker = _trackers[pointer];
// If tracker isn't in the list, check if this is the first tap tracker
if (tracker == null && _firstTap != null && _firstTap!.pointer == pointer) {
tracker = _firstTap;
}
// If tracker is still null, we rejected ourselves already
if (tracker != null) {
_reject(tracker);
}
}
void _reject(_TapTracker tracker) {
_trackers.remove(tracker.pointer);
tracker.entry.resolve(GestureDisposition.rejected);
_freezeTracker(tracker);
if (_firstTap != null) {
if (tracker == _firstTap) {
_reset();
} else {
_checkCancel();
if (_trackers.isEmpty) {
_reset();
}
}
}
}
@override
void dispose() {
_reset();
super.dispose();
}
void _reset() {
_stopFirstTapUpTimer();
_firstTap = null;
_clearTrackers();
}
void _registerTap(_TapTracker tracker) {
GestureBinding.instance.gestureArena.hold(tracker.pointer);
// Note, order is important below in order for the clear -> reject logic to
// work properly.
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
assert(_trackers.isEmpty);
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _startFirstTapDownTimer() {
_firstTapTimer ??= Timer(kDoubleTapTimeout, _timeoutCheck);
}
void _stopFirstTapUpTimer() {
if (_firstTapTimer != null) {
_firstTapTimer!.cancel();
_firstTapTimer = null;
}
}
void _timeoutCheck() {
_isStart = false;
if (_upTap.length == 2) {
_resolve();
} else {
_reset();
}
_upTap.clear();
}
void _resolve() {
// TODO tap down details
if (onDoubleFinerTap != null) {
onDoubleFinerTap!(TapDownDetails(
kind: _lastPointerDownEvent?.kind,
));
}
_trackers.forEach((key, value) {
value.entry.resolve(GestureDisposition.accepted);
});
_reset();
}
void _checkCancel() {
if (onDoubleFinerTapCancel != null) {
invokeCallback<void>('onHoldDragCancel', onDoubleFinerTapCancel!);
}
}
@override
String get debugDescription => 'double tap';
}
/// TapTracker helps track individual tap sequences as part of a
/// larger gesture.
class _TapTracker {
_TapTracker({
required PointerDownEvent event,
required this.entry,
required Duration doubleTapMinTime,
required this.gestureSettings,
}) : pointer = event.pointer,
_initialGlobalPosition = event.position,
initialButtons = event.buttons,
_doubleTapMinTimeCountdown =
_CountdownZoned(duration: doubleTapMinTime);
final DeviceGestureSettings? gestureSettings;
final int pointer;
final GestureArenaEntry entry;
final Offset _initialGlobalPosition;
final int initialButtons;
final _CountdownZoned _doubleTapMinTimeCountdown;
bool _isTrackingPointer = false;
void startTrackingPointer(PointerRoute route, Matrix4? transform) {
if (!_isTrackingPointer) {
_isTrackingPointer = true;
GestureBinding.instance.pointerRouter.addRoute(pointer, route, transform);
}
}
void stopTrackingPointer(PointerRoute route) {
if (_isTrackingPointer) {
_isTrackingPointer = false;
GestureBinding.instance.pointerRouter.removeRoute(pointer, route);
}
}
bool isWithinGlobalTolerance(PointerEvent event, double tolerance) {
final Offset offset = event.position - _initialGlobalPosition;
return offset.distance <= tolerance;
}
bool hasElapsedMinTime() {
return _doubleTapMinTimeCountdown.timeout;
}
bool hasSameButton(PointerDownEvent event) {
return event.buttons == initialButtons;
}
}
/// CountdownZoned tracks whether the specified duration has elapsed since
/// creation, honoring [Zone].
class _CountdownZoned {
_CountdownZoned({required Duration duration}) {
Timer(duration, _onTimeout);
}
bool _timeout = false;
bool get timeout => _timeout;
void _onTimeout() {
_timeout = true;
}
}
RawGestureDetector getMixinGestureDetector({
Widget? child,
GestureTapUpCallback? onTapUp,
GestureTapDownCallback? onDoubleTapDown,
GestureDoubleTapCallback? onDoubleTap,
GestureLongPressDownCallback? onLongPressDown,
GestureLongPressCallback? onLongPress,
GestureDragStartCallback? onHoldDragStart,
GestureDragUpdateCallback? onHoldDragUpdate,
GestureDragCancelCallback? onHoldDragCancel,
GestureDragEndCallback? onHoldDragEnd,
GestureTapDownCallback? onDoubleFinerTap,
GestureDragStartCallback? onOneFingerPanStart,
GestureDragUpdateCallback? onOneFingerPanUpdate,
GestureDragEndCallback? onOneFingerPanEnd,
GestureDragCancelCallback? onOneFingerPanCancel,
GestureScaleUpdateCallback? onTwoFingerScaleUpdate,
GestureScaleEndCallback? onTwoFingerScaleEnd,
GestureDragUpdateCallback? onThreeFingerVerticalDragUpdate,
}) {
return RawGestureDetector(
child: child,
gestures: <Type, GestureRecognizerFactory>{
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(), (instance) {
instance.onTapUp = onTapUp;
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPress = onLongPress;
}),
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
() => HoldTapMoveGestureRecognizer(),
(instance) => instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
..onHoldDragCancel = onHoldDragCancel
..onHoldDragEnd = onHoldDragEnd),
DoubleFinerTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
DoubleFinerTapGestureRecognizer>(
() => DoubleFinerTapGestureRecognizer(), (instance) {
instance.onDoubleFinerTap = onDoubleFinerTap;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
() => CustomTouchGestureRecognizer(), (instance) {
instance
..onOneFingerPanStart = onOneFingerPanStart
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
}),
});
}
+790
View File
@@ -0,0 +1,790 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/user_model.dart';
import 'package:get/get.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../common.dart';
import './dialog.dart';
const kOpSvgList = [
'github',
'gitlab',
'google',
'apple',
'okta',
'facebook',
'azure',
'auth0',
'microsoft'
];
class _IconOP extends StatelessWidget {
final String op;
final String? icon;
final EdgeInsets margin;
const _IconOP(
{Key? key,
required this.op,
required this.icon,
this.margin = const EdgeInsets.symmetric(horizontal: 4.0)})
: super(key: key);
@override
Widget build(BuildContext context) {
final svgFile =
kOpSvgList.contains(op.toLowerCase()) ? op.toLowerCase() : 'default';
return Container(
margin: margin,
child: icon == null
? SvgPicture.asset(
'assets/auth-$svgFile.svg',
width: 20,
)
: SvgPicture.string(
icon!,
width: 20,
),
);
}
}
class ButtonOP extends StatelessWidget {
final String op;
final RxString curOP;
final String? icon;
final Color primaryColor;
final double height;
final Function() onTap;
const ButtonOP({
Key? key,
required this.op,
required this.curOP,
required this.icon,
required this.primaryColor,
required this.height,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final opLabel = {
'github': 'GitHub',
'gitlab': 'GitLab'
}[op.toLowerCase()] ??
toCapitalized(op);
return Row(children: [
Container(
height: height,
width: 200,
child: Obx(() => ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: curOP.value.isEmpty || curOP.value == op
? primaryColor
: Colors.grey,
).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)),
onPressed: curOP.value.isEmpty || curOP.value == op ? onTap : null,
child: Row(
children: [
SizedBox(
width: 30,
child: _IconOP(
op: op,
icon: icon,
margin: EdgeInsets.only(right: 5),
),
),
Expanded(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Center(
child: Text(translate("Continue with {$opLabel}"))),
),
),
],
))),
),
]);
}
}
class ConfigOP {
final String op;
final String? icon;
ConfigOP({required this.op, required this.icon});
}
class WidgetOP extends StatefulWidget {
final ConfigOP config;
final RxString curOP;
final Function(Map<String, dynamic>) cbLogin;
const WidgetOP({
Key? key,
required this.config,
required this.curOP,
required this.cbLogin,
}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _WidgetOPState();
}
}
class _WidgetOPState extends State<WidgetOP> {
Timer? _updateTimer;
String _stateMsg = '';
String _failedMsg = '';
String _url = '';
@override
void dispose() {
super.dispose();
_updateTimer?.cancel();
}
_beginQueryState() {
_updateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
_updateState();
});
}
_updateState() {
bind.mainAccountAuthResult().then((result) {
if (result.isEmpty) {
return;
}
final resultMap = jsonDecode(result);
if (resultMap == null) {
return;
}
final String stateMsg = resultMap['state_msg'];
String failedMsg = resultMap['failed_msg'];
final String? url = resultMap['url'];
final bool urlLaunched = (resultMap['url_launched'] as bool?) ?? false;
final authBody = resultMap['auth_body'];
if (_stateMsg != stateMsg || _failedMsg != failedMsg) {
if (_url.isEmpty && url != null && url.isNotEmpty) {
if (!urlLaunched) {
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
_url = url;
}
if (authBody != null) {
_updateTimer?.cancel();
widget.curOP.value = '';
widget.cbLogin(authBody as Map<String, dynamic>);
}
setState(() {
_stateMsg = stateMsg;
_failedMsg = failedMsg;
if (failedMsg.isNotEmpty) {
widget.curOP.value = '';
_updateTimer?.cancel();
}
});
}
});
}
_resetState() {
_stateMsg = '';
_failedMsg = '';
_url = '';
}
@override
Widget build(BuildContext context) {
return Column(
children: [
ButtonOP(
op: widget.config.op,
curOP: widget.curOP,
icon: widget.config.icon,
primaryColor: str2color(widget.config.op, 0x7f),
height: 36,
onTap: () async {
_resetState();
widget.curOP.value = widget.config.op;
await bind.mainAccountAuth(op: widget.config.op, rememberMe: true);
_beginQueryState();
},
),
Obx(() {
if (widget.curOP.isNotEmpty &&
widget.curOP.value != widget.config.op) {
_failedMsg = '';
}
return Offstage(
offstage:
_failedMsg.isEmpty && widget.curOP.value != widget.config.op,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (_stateMsg.isNotEmpty && _failedMsg.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SelectableText(
translate(_stateMsg),
style: DefaultTextStyle.of(context)
.style
.copyWith(fontSize: 12),
),
),
if (_failedMsg.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Builder(builder: (context) {
final errorColor =
Theme.of(context).colorScheme.error;
final bgColor = Theme.of(context)
.colorScheme
.errorContainer
.withOpacity(0.3);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 6.0),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline,
color: errorColor, size: 16),
const SizedBox(width: 6),
Flexible(
child: SelectableText(
translate(_failedMsg),
style: DefaultTextStyle.of(context)
.style
.copyWith(
fontSize: 13,
color: errorColor,
),
),
),
],
),
);
}),
),
],
),
);
}),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: const SizedBox(
height: 5.0,
),
),
),
Obx(
() => Offstage(
offstage: widget.curOP.value != widget.config.op,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 20),
child: ElevatedButton(
onPressed: () {
widget.curOP.value = '';
_updateTimer?.cancel();
_resetState();
bind.mainAccountAuthCancel();
},
child: Text(
translate('Cancel'),
style: TextStyle(fontSize: 15),
),
),
),
),
),
],
);
}
}
class LoginWidgetOP extends StatelessWidget {
final List<ConfigOP> ops;
final RxString curOP;
final Function(Map<String, dynamic>) cbLogin;
LoginWidgetOP({
Key? key,
required this.ops,
required this.curOP,
required this.cbLogin,
}) : super(key: key);
@override
Widget build(BuildContext context) {
var children = ops
.map((op) => [
WidgetOP(
config: op,
curOP: curOP,
cbLogin: cbLogin,
),
const Divider(
indent: 5,
endIndent: 5,
)
])
.expand((i) => i)
.toList();
if (children.isNotEmpty) {
children.removeLast();
}
return SingleChildScrollView(
child: Container(
width: 200,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: children,
)));
}
}
class LoginWidgetUserPass extends StatelessWidget {
final TextEditingController username;
final TextEditingController pass;
final String? usernameMsg;
final String? passMsg;
final bool isInProgress;
final RxString curOP;
final Function() onLogin;
final FocusNode? userFocusNode;
const LoginWidgetUserPass({
Key? key,
this.userFocusNode,
required this.username,
required this.pass,
required this.usernameMsg,
required this.passMsg,
required this.isInProgress,
required this.curOP,
required this.onLogin,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 8.0),
DialogTextField(
title: translate(DialogTextField.kUsernameTitle),
controller: username,
focusNode: userFocusNode,
prefixIcon: DialogTextField.kUsernameIcon,
errorText: usernameMsg),
PasswordWidget(
controller: pass,
autoFocus: false,
reRequestFocus: true,
errorText: passMsg,
),
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress) const LinearProgressIndicator(),
const SizedBox(height: 12.0),
FittedBox(
child:
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(
height: 38,
width: 200,
child: Obx(() => ElevatedButton(
child: Text(
translate('Login'),
style: TextStyle(fontSize: 16),
),
onPressed:
curOP.value.isEmpty || curOP.value == 'rustdesk'
? () {
onLogin();
}
: null,
)),
),
])),
],
));
}
}
const kAuthReqTypeOidc = 'oidc/';
// call this directly
Future<bool?> loginDialog() async {
var username =
TextEditingController(text: UserModel.getLocalUserInfo()?['name'] ?? '');
var password = TextEditingController();
final userFocusNode = FocusNode()..requestFocus();
Timer(Duration(milliseconds: 100), () => userFocusNode..requestFocus());
String? usernameMsg;
String? passwordMsg;
var isInProgress = false;
final RxString curOP = ''.obs;
// Track hover state for the close icon
bool isCloseHovered = false;
final loginOptions = [].obs;
Future.delayed(Duration.zero, () async {
loginOptions.value = await UserModel.queryOidcLoginOptions();
});
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
username.addListener(() {
if (usernameMsg != null) {
setState(() => usernameMsg = null);
}
});
password.addListener(() {
if (passwordMsg != null) {
setState(() => passwordMsg = null);
}
});
onDialogCancel() {
isInProgress = false;
close(false);
}
handleLoginResponse(LoginResponse resp, bool storeIfAccessToken,
void Function([dynamic])? close) async {
switch (resp.type) {
case HttpType.kAuthResTypeToken:
if (resp.access_token != null) {
if (storeIfAccessToken) {
await bind.mainSetLocalOption(
key: 'access_token', value: resp.access_token!);
await bind.mainSetLocalOption(
key: 'user_info', value: jsonEncode(resp.user ?? {}));
}
if (close != null) {
close(true);
}
return;
}
break;
case HttpType.kAuthResTypeEmailCheck:
bool? isEmailVerification;
if (resp.tfa_type == null ||
resp.tfa_type == HttpType.kAuthResTypeEmailCheck) {
isEmailVerification = true;
} else if (resp.tfa_type == HttpType.kAuthResTypeTfaCheck) {
isEmailVerification = false;
} else {
passwordMsg = "Failed, bad tfa type from server";
}
if (isEmailVerification != null) {
if (isMobile) {
if (close != null) close(null);
verificationCodeDialog(
resp.user, resp.secret, isEmailVerification);
} else {
setState(() => isInProgress = false);
// Workaround for web, close the dialog first, then show the verification code dialog.
// Otherwise, the text field will keep selecting the text and we can't input the code.
// Not sure why this happens.
if (isWeb && close != null) close(null);
final res = await verificationCodeDialog(
resp.user, resp.secret, isEmailVerification);
if (res == true) {
if (!isWeb && close != null) close(false);
return;
}
}
}
break;
default:
passwordMsg = "Failed, bad response from server";
break;
}
}
onLogin() async {
// validate
if (username.text.isEmpty) {
setState(() => usernameMsg = translate('Username missed'));
return;
}
if (password.text.isEmpty) {
setState(() => passwordMsg = translate('Password missed'));
return;
}
curOP.value = 'rustdesk';
setState(() => isInProgress = true);
try {
final resp = await gFFI.userModel.login(LoginRequest(
username: username.text,
password: password.text,
id: await bind.mainGetMyId(),
uuid: await bind.mainGetUuid(),
autoLogin: true,
type: HttpType.kAuthReqTypeAccount));
await handleLoginResponse(resp, true, close);
} on RequestException catch (err) {
passwordMsg = translate(err.cause);
} catch (err) {
passwordMsg = "Unknown Error: $err";
}
curOP.value = '';
setState(() => isInProgress = false);
}
thirdAuthWidget() => Obx(() {
return Offstage(
offstage: loginOptions.isEmpty,
child: Column(
children: [
const SizedBox(
height: 8.0,
),
Center(
child: Text(
translate('or'),
style: TextStyle(fontSize: 16),
)),
const SizedBox(
height: 8.0,
),
LoginWidgetOP(
ops: loginOptions
.map((e) => ConfigOP(op: e['name'], icon: e['icon']))
.toList(),
curOP: curOP,
cbLogin: (Map<String, dynamic> authBody) async {
LoginResponse? resp;
try {
// access_token is already stored in the rust side.
resp =
gFFI.userModel.getLoginResponseFromAuthBody(authBody);
} catch (e) {
debugPrint(
'Failed to parse oidc login body: "$authBody"');
}
close(true);
if (resp != null) {
handleLoginResponse(resp, false, null);
}
},
),
],
),
);
});
final title = Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
translate('Login'),
).marginOnly(top: MyTheme.dialogPadding),
MouseRegion(
onEnter: (_) => setState(() => isCloseHovered = true),
onExit: (_) => setState(() => isCloseHovered = false),
child: InkWell(
child: Icon(
Icons.close,
size: 25,
// No need to handle the branch of null.
// Because we can ensure the color is not null when debug.
color: isCloseHovered
? Colors.white
: Theme.of(context)
.textTheme
.titleLarge
?.color
?.withOpacity(0.55),
),
onTap: onDialogCancel,
hoverColor: Colors.red,
borderRadius: BorderRadius.circular(5),
),
).marginOnly(top: 10, right: 15),
],
);
final titlePadding = EdgeInsets.fromLTRB(MyTheme.dialogPadding, 0, 0, 0);
return CustomAlertDialog(
title: title,
titlePadding: titlePadding,
contentBoxConstraints: BoxConstraints(minWidth: 400),
content: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
height: 8.0,
),
LoginWidgetUserPass(
username: username,
pass: password,
usernameMsg: usernameMsg,
passMsg: passwordMsg,
isInProgress: isInProgress,
curOP: curOP,
onLogin: onLogin,
userFocusNode: userFocusNode,
),
thirdAuthWidget(),
],
),
onCancel: onDialogCancel,
onSubmit: onLogin,
);
});
if (res != null) {
await UserModel.updateOtherModels();
}
return res;
}
Future<bool?> verificationCodeDialog(
UserPayload? user, String? secret, bool isEmailVerification) async {
var autoLogin = true;
var isInProgress = false;
String? errorText;
final code = TextEditingController();
final res = await gFFI.dialogManager.show<bool>((setState, close, context) {
void onVerify() async {
setState(() => isInProgress = true);
try {
final resp = await gFFI.userModel.login(LoginRequest(
verificationCode: code.text,
tfaCode: isEmailVerification ? null : code.text,
secret: secret,
username: user?.name,
id: await bind.mainGetMyId(),
uuid: await bind.mainGetUuid(),
autoLogin: autoLogin,
type: HttpType.kAuthReqTypeEmailCode));
switch (resp.type) {
case HttpType.kAuthResTypeToken:
if (resp.access_token != null) {
await bind.mainSetLocalOption(
key: 'access_token', value: resp.access_token!);
close(true);
return;
}
break;
default:
errorText = "Failed, bad response from server";
break;
}
} on RequestException catch (err) {
errorText = translate(err.cause);
} catch (err) {
errorText = "Unknown Error: $err";
}
setState(() => isInProgress = false);
}
final codeField = isEmailVerification
? DialogEmailCodeField(
controller: code,
errorText: errorText,
readyCallback: onVerify,
onChanged: () => errorText = null,
)
: Dialog2FaField(
controller: code,
errorText: errorText,
readyCallback: onVerify,
onChanged: () => errorText = null,
);
getOnSubmit() => codeField.isReady ? onVerify : null;
return CustomAlertDialog(
title: Text(translate("Verification code")),
contentBoxConstraints: BoxConstraints(maxWidth: 300),
content: Column(
children: [
Offstage(
offstage: !isEmailVerification || user?.email == null,
child: TextField(
decoration: InputDecoration(
labelText: "Email", prefixIcon: Icon(Icons.email)),
readOnly: true,
controller: TextEditingController(text: user?.email),
).workaroundFreezeLinuxMint()),
isEmailVerification ? const SizedBox(height: 8) : const Offstage(),
codeField,
/*
CheckboxListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
controlAffinity: ListTileControlAffinity.leading,
title: Row(children: [
Expanded(child: Text(translate("Trust this device")))
]),
value: trustThisDevice,
onChanged: (v) {
if (v == null) return;
setState(() => trustThisDevice = !trustThisDevice);
},
),
*/
// NOT use Offstage to wrap LinearProgressIndicator
if (isInProgress) const LinearProgressIndicator(),
],
),
onCancel: close,
onSubmit: getOnSubmit(),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("Verify", onPressed: getOnSubmit()),
]);
});
// For verification code, desktop update other models in login dialog, mobile need to close login dialog first,
// otherwise the soft keyboard will jump out on each key press, so mobile update in verification code dialog.
if (isMobile && res == true) {
await UserModel.updateOtherModels();
}
return res;
}
void logOutConfirmDialog() {
gFFI.dialogManager.show((setState, close, context) {
submit() {
close();
gFFI.userModel.logOut();
}
return CustomAlertDialog(
content: Text(translate("logout_tip")),
actions: [
dialogButton(translate("Cancel"), onPressed: close, isOutline: true),
dialogButton(translate("OK"), onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
});
}
+309
View File
@@ -0,0 +1,309 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/hbbs/hbbs.dart';
import 'package:flutter_hbb/common/widgets/login.dart';
import 'package:flutter_hbb/common/widgets/peers_view.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import '../../common.dart';
class MyGroup extends StatefulWidget {
final EdgeInsets? menuPadding;
const MyGroup({Key? key, this.menuPadding}) : super(key: key);
@override
State<StatefulWidget> createState() {
return _MyGroupState();
}
}
class _MyGroupState extends State<MyGroup> {
RxBool get isSelectedDeviceGroup => gFFI.groupModel.isSelectedDeviceGroup;
RxString get selectedAccessibleItemName =>
gFFI.groupModel.selectedAccessibleItemName;
RxString get searchAccessibleItemNameText =>
gFFI.groupModel.searchAccessibleItemNameText;
static TextEditingController searchUserController = TextEditingController();
@override
Widget build(BuildContext context) {
return Obx(() {
if (!gFFI.userModel.isLogin) {
return Center(
child: ElevatedButton(
onPressed: loginDialog, child: Text(translate("Login"))));
} else if (gFFI.userModel.networkError.isNotEmpty) {
return netWorkErrorWidget();
} else if (gFFI.groupModel.groupLoading.value && gFFI.groupModel.emtpy) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Column(
children: [
buildErrorBanner(context,
loading: gFFI.groupModel.groupLoading,
err: gFFI.groupModel.groupLoadError,
retry: null,
close: () => gFFI.groupModel.groupLoadError.value = ''),
Expanded(
child: Obx(() => stateGlobal.isPortrait.isTrue
? _buildPortrait()
: _buildLandscape())),
],
);
});
}
Widget _buildLandscape() {
return Row(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: Theme.of(context).colorScheme.background)),
child: Container(
width: 150,
height: double.infinity,
child: Column(
children: [
_buildLeftHeader(),
Expanded(
child: Container(
width: double.infinity,
height: double.infinity,
child: _buildLeftList(),
),
)
],
),
),
).marginOnly(right: 12.0),
Expanded(
child: Align(
alignment: Alignment.topLeft,
child: MyGroupPeerView(
menuPadding: widget.menuPadding,
)),
)
],
);
}
Widget _buildPortrait() {
return Column(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border:
Border.all(color: Theme.of(context).colorScheme.background)),
child: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildLeftHeader(),
Container(
width: double.infinity,
child: _buildLeftList(),
)
],
),
),
).marginOnly(bottom: 12.0),
Expanded(
child: Align(
alignment: Alignment.topLeft,
child: MyGroupPeerView(
menuPadding: widget.menuPadding,
)),
)
],
);
}
Widget _buildLeftHeader() {
final fontSize = 14.0;
return Row(
children: [
Expanded(
child: TextField(
controller: searchUserController,
onChanged: (value) {
searchAccessibleItemNameText.value = value;
selectedAccessibleItemName.value = '';
},
textAlignVertical: TextAlignVertical.center,
style: TextStyle(fontSize: fontSize),
decoration: InputDecoration(
filled: false,
prefixIcon: Icon(
Icons.search_rounded,
color: Theme.of(context).hintColor,
).paddingOnly(top: 2),
hintText: translate("Search"),
hintStyle: TextStyle(fontSize: fontSize),
border: InputBorder.none,
isDense: true,
),
).workaroundFreezeLinuxMint()),
],
);
}
Widget _buildLeftList() {
return Obx(() {
final userItems = gFFI.groupModel.users.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
final search = searchAccessibleItemNameText.value.toLowerCase();
return p0.name.toLowerCase().contains(search) ||
p0.displayNameOrName.toLowerCase().contains(search);
}
return true;
}).toList();
// Count occurrences of each displayNameOrName to detect duplicates
final displayNameCount = <String, int>{};
for (final u in userItems) {
final dn = u.displayNameOrName;
displayNameCount[dn] = (displayNameCount[dn] ?? 0) + 1;
}
final deviceGroupItems = gFFI.groupModel.deviceGroups.where((p0) {
if (searchAccessibleItemNameText.isNotEmpty) {
return p0.name
.toLowerCase()
.contains(searchAccessibleItemNameText.value.toLowerCase());
}
return true;
}).toList();
listView(bool isPortrait) => ListView.builder(
shrinkWrap: isPortrait,
itemCount: deviceGroupItems.length + userItems.length,
itemBuilder: (context, index) => index < deviceGroupItems.length
? _buildDeviceGroupItem(deviceGroupItems[index])
: _buildUserItem(userItems[index - deviceGroupItems.length],
displayNameCount));
var maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
return Obx(() => stateGlobal.isPortrait.isFalse
? listView(false)
: LimitedBox(maxHeight: maxHeight, child: listView(true)));
});
}
Widget _buildUserItem(UserPayload user, Map<String, int> displayNameCount) {
final username = user.name;
final dn = user.displayNameOrName;
final isDuplicate = (displayNameCount[dn] ?? 0) > 1;
final displayName =
isDuplicate && user.displayName.trim().isNotEmpty
? '${user.displayName} (@$username)'
: dn;
return InkWell(onTap: () {
isSelectedDeviceGroup.value = false;
if (selectedAccessibleItemName.value != username) {
selectedAccessibleItemName.value = username;
} else {
selectedAccessibleItemName.value = '';
}
}, child: Obx(
() {
bool selected = !isSelectedDeviceGroup.value &&
selectedAccessibleItemName.value == username;
final isMe = username == gFFI.userModel.userName.value;
final colorMe = MyTheme.color(context).me!;
return Container(
decoration: BoxDecoration(
color: selected ? MyTheme.color(context).highlight : null,
border: Border(
bottom: BorderSide(
width: 0.7,
color: Theme.of(context).dividerColor.withOpacity(0.1))),
),
child: Container(
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: str2color(username, 0xAF),
shape: BoxShape.circle,
),
child: Align(
alignment: Alignment.center,
child: Center(
child: Text(
displayName.characters.first.toUpperCase(),
style: TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
).marginOnly(right: 4),
if (isMe) Flexible(child: Text(displayName)),
if (isMe)
Flexible(
child: Container(
margin: EdgeInsets.only(left: 5),
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: colorMe.withAlpha(20),
borderRadius: BorderRadius.all(Radius.circular(2)),
border: Border.all(color: colorMe.withAlpha(100))),
child: Text(
translate('Me'),
style: TextStyle(
color: colorMe.withAlpha(200), fontSize: 12),
),
),
),
if (!isMe) Expanded(child: Text(displayName)),
],
).paddingSymmetric(vertical: 4),
),
);
},
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
}
Widget _buildDeviceGroupItem(DeviceGroupPayload deviceGroup) {
final name = deviceGroup.name;
return InkWell(onTap: () {
isSelectedDeviceGroup.value = true;
if (selectedAccessibleItemName.value != name) {
selectedAccessibleItemName.value = name;
} else {
selectedAccessibleItemName.value = '';
}
}, child: Obx(
() {
bool selected = isSelectedDeviceGroup.value &&
selectedAccessibleItemName.value == name;
return Container(
decoration: BoxDecoration(
color: selected ? MyTheme.color(context).highlight : null,
border: Border(
bottom: BorderSide(
width: 0.7,
color: Theme.of(context).dividerColor.withOpacity(0.1))),
),
child: Container(
child: Row(
children: [
Container(
width: 20,
height: 20,
child: Icon(IconFont.deviceGroupOutline,
color: MyTheme.accent, size: 19),
).marginOnly(right: 4),
Expanded(child: Text(name)),
],
).paddingSymmetric(vertical: 4),
),
);
},
)).marginSymmetric(horizontal: 12).marginOnly(bottom: 6);
}
}
+674
View File
@@ -0,0 +1,674 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import '../../consts.dart';
import '../../desktop/widgets/tabbar_widget.dart';
import '../../models/chat_model.dart';
import '../../models/model.dart';
import 'chat_page.dart';
class DraggableChatWindow extends StatelessWidget {
const DraggableChatWindow(
{Key? key,
this.position = Offset.zero,
required this.width,
required this.height,
required this.chatModel})
: super(key: key);
final Offset position;
final double width;
final double height;
final ChatModel chatModel;
@override
Widget build(BuildContext context) {
if (draggablePositions.chatWindow.isInvalid()) {
draggablePositions.chatWindow.update(position);
}
return isIOS
? IOSDraggable(
position: draggablePositions.chatWindow,
chatModel: chatModel,
width: width,
height: height,
builder: (context) {
return Column(
children: [
_buildMobileAppBar(context),
Expanded(
child: ChatPage(chatModel: chatModel),
),
],
);
},
)
: Draggable(
checkKeyboard: true,
checkScreenSize: true,
position: draggablePositions.chatWindow,
width: width,
height: height,
chatModel: chatModel,
builder: (context, onPanUpdate) {
final child = Scaffold(
resizeToAvoidBottomInset: false,
appBar: CustomAppBar(
onPanUpdate: onPanUpdate,
appBar: (isDesktop || isWebDesktop)
? _buildDesktopAppBar(context)
: _buildMobileAppBar(context),
),
body: ChatPage(chatModel: chatModel),
);
return Container(
decoration:
BoxDecoration(border: Border.all(color: MyTheme.border)),
child: child);
});
}
Widget _buildMobileAppBar(BuildContext context) {
return Container(
color: Theme.of(context).colorScheme.primary,
height: 50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: Text(
translate("Chat"),
style: const TextStyle(
color: Colors.white,
fontFamily: 'WorkSans',
fontWeight: FontWeight.bold,
fontSize: 20),
)),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
IconButton(
onPressed: () {
chatModel.hideChatWindowOverlay();
},
icon: const Icon(
Icons.keyboard_arrow_down,
color: Colors.white,
)),
IconButton(
onPressed: () {
chatModel.hideChatWindowOverlay();
chatModel.hideChatIconOverlay();
},
icon: const Icon(
Icons.close,
color: Colors.white,
))
],
)
],
),
);
}
Widget _buildDesktopAppBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).hintColor.withOpacity(0.4)))),
height: 38,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Obx(() => Opacity(
opacity: chatModel.isWindowFocus.value ? 1.0 : 0.4,
child: Row(children: [
Icon(Icons.chat_bubble_outline,
size: 20, color: Theme.of(context).colorScheme.primary),
SizedBox(width: 6),
Text(translate("Chat"))
])))),
Padding(
padding: EdgeInsets.all(2),
child: ActionIcon(
message: 'Close',
icon: IconFont.close,
onTap: chatModel.hideChatWindowOverlay,
isClose: true,
boxSize: 32,
))
],
),
);
}
}
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final GestureDragUpdateCallback onPanUpdate;
final Widget appBar;
const CustomAppBar(
{Key? key, required this.onPanUpdate, required this.appBar})
: super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(onPanUpdate: onPanUpdate, child: appBar);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
/// floating buttons of back/home/recent actions for android
class DraggableMobileActions extends StatelessWidget {
DraggableMobileActions(
{this.onBackPressed,
this.onRecentPressed,
this.onHomePressed,
this.onHidePressed,
required this.position,
required this.width,
required this.height,
required this.scale});
final double scale;
final DraggableKeyPosition position;
final double width;
final double height;
final VoidCallback? onBackPressed;
final VoidCallback? onHomePressed;
final VoidCallback? onRecentPressed;
final VoidCallback? onHidePressed;
@override
Widget build(BuildContext context) {
return Draggable(
position: position,
width: scale * width,
height: scale * height,
builder: (_, onPanUpdate) {
return GestureDetector(
onPanUpdate: onPanUpdate,
child: Card(
color: Colors.transparent,
shadowColor: Colors.transparent,
child: Container(
decoration: BoxDecoration(
color: MyTheme.accent.withOpacity(0.4),
borderRadius:
BorderRadius.all(Radius.circular(15 * scale))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
color: Colors.white,
onPressed: onBackPressed,
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.arrow_back),
iconSize: 24 * scale),
IconButton(
color: Colors.white,
onPressed: onHomePressed,
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.home),
iconSize: 24 * scale),
IconButton(
color: Colors.white,
onPressed: onRecentPressed,
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.more_horiz),
iconSize: 24 * scale),
const VerticalDivider(
width: 0,
thickness: 2,
indent: 10,
endIndent: 10,
),
IconButton(
color: Colors.white,
onPressed: onHidePressed,
splashRadius: kDesktopIconButtonSplashRadius,
icon: const Icon(Icons.keyboard_arrow_down),
iconSize: 24 * scale),
],
),
)));
});
}
}
class DraggableKeyPosition {
final String key;
Offset _pos;
late Debouncer<int> _debouncerStore;
DraggableKeyPosition(this.key)
: _pos = DraggablePositions.kInvalidDraggablePosition;
get pos => _pos;
_loadPosition(String k) {
final value = bind.getLocalFlutterOption(k: k);
if (value.isNotEmpty) {
final parts = value.split(',');
if (parts.length == 2) {
return Offset(double.parse(parts[0]), double.parse(parts[1]));
}
}
return DraggablePositions.kInvalidDraggablePosition;
}
load() {
_pos = _loadPosition(key);
_debouncerStore = Debouncer<int>(const Duration(milliseconds: 500),
onChanged: (v) => _store(), initialValue: 0);
}
update(Offset pos) {
_pos = pos;
_triggerStore();
}
// Adjust position to keep it in the screen
// Only used for desktop and web desktop
tryAdjust(double w, double h, double scale) {
final size = MediaQuery.of(Get.context!).size;
w = w * scale;
h = h * scale;
double x = _pos.dx;
double y = _pos.dy;
if (x + w > size.width) {
x = size.width - w;
}
final tabBarHeight = isDesktop ? kDesktopRemoteTabBarHeight : 0;
if (y + h > (size.height - tabBarHeight)) {
y = size.height - tabBarHeight - h;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
if (x != _pos.dx || y != _pos.dy) {
update(Offset(x, y));
}
}
isInvalid() {
return _pos == DraggablePositions.kInvalidDraggablePosition;
}
_triggerStore() => _debouncerStore.value = _debouncerStore.value + 1;
_store() {
bind.setLocalFlutterOption(k: key, v: '${_pos.dx},${_pos.dy}');
}
}
class DraggablePositions {
static const kChatWindow = 'draggablePositionChat';
static const kMobileActions = 'draggablePositionMobile';
static const kIOSDraggable = 'draggablePositionIOS';
static const kInvalidDraggablePosition = Offset(-999999, -999999);
final chatWindow = DraggableKeyPosition(kChatWindow);
final mobileActions = DraggableKeyPosition(kMobileActions);
final iOSDraggable = DraggableKeyPosition(kIOSDraggable);
load() {
chatWindow.load();
mobileActions.load();
iOSDraggable.load();
}
}
DraggablePositions draggablePositions = DraggablePositions();
class Draggable extends StatefulWidget {
Draggable(
{Key? key,
this.checkKeyboard = false,
this.checkScreenSize = false,
required this.position,
required this.width,
required this.height,
this.chatModel,
required this.builder})
: super(key: key);
final bool checkKeyboard;
final bool checkScreenSize;
final DraggableKeyPosition position;
final double width;
final double height;
final ChatModel? chatModel;
final Widget Function(BuildContext, GestureDragUpdateCallback) builder;
@override
State<StatefulWidget> createState() => _DraggableState(chatModel);
}
class _DraggableState extends State<Draggable> {
late ChatModel? _chatModel;
bool _keyboardVisible = false;
double _saveHeight = 0;
double _lastBottomHeight = 0;
_DraggableState(ChatModel? chatModel) {
_chatModel = chatModel;
}
get position => widget.position.pos;
void onPanUpdate(DragUpdateDetails d) {
final offset = d.delta;
final size = MediaQuery.of(context).size;
double x = 0;
double y = 0;
if (position.dx + offset.dx + widget.width > size.width) {
x = size.width - widget.width;
} else if (position.dx + offset.dx < 0) {
x = 0;
} else {
x = position.dx + offset.dx;
}
if (position.dy + offset.dy + widget.height > size.height) {
y = size.height - widget.height;
} else if (position.dy + offset.dy < 0) {
y = 0;
} else {
y = position.dy + offset.dy;
}
setState(() {
widget.position.update(Offset(x, y));
});
_chatModel?.setChatWindowPosition(position);
}
checkScreenSize() {
// Ensure the draggable always stays within current screen bounds
widget.position.tryAdjust(widget.width, widget.height, 1);
}
checkKeyboard() {
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
final currentVisible = bottomHeight != 0;
// save
if (!_keyboardVisible && currentVisible) {
_saveHeight = position.dy;
}
// reset
if (_lastBottomHeight > 0 && bottomHeight == 0) {
setState(() {
widget.position.update(Offset(position.dx, _saveHeight));
});
}
// onKeyboardVisible
if (_keyboardVisible && currentVisible) {
final sumHeight = bottomHeight + widget.height;
final contextHeight = MediaQuery.of(context).size.height;
if (sumHeight + position.dy > contextHeight) {
final y = contextHeight - sumHeight;
setState(() {
widget.position.update(Offset(position.dx, y));
});
}
}
_keyboardVisible = currentVisible;
_lastBottomHeight = bottomHeight;
}
@override
Widget build(BuildContext context) {
if (widget.checkKeyboard) {
checkKeyboard();
}
if (widget.checkScreenSize) {
checkScreenSize();
}
return Stack(children: [
Positioned(
top: position.dy,
left: position.dx,
width: widget.width,
height: widget.height,
child: widget.builder(context, onPanUpdate))
]);
}
}
class IOSDraggable extends StatefulWidget {
const IOSDraggable(
{Key? key,
this.chatModel,
required this.position,
required this.width,
required this.height,
required this.builder})
: super(key: key);
final DraggableKeyPosition position;
final ChatModel? chatModel;
final double width;
final double height;
final Widget Function(BuildContext) builder;
@override
IOSDraggableState createState() =>
IOSDraggableState(chatModel, width, height);
}
class IOSDraggableState extends State<IOSDraggable> {
late ChatModel? _chatModel;
late double _width;
late double _height;
bool _keyboardVisible = false;
double _saveHeight = 0;
double _lastBottomHeight = 0;
IOSDraggableState(ChatModel? chatModel, double w, double h) {
_chatModel = chatModel;
_width = w;
_height = h;
}
DraggableKeyPosition get position => widget.position;
checkKeyboard() {
final bottomHeight = MediaQuery.of(context).viewInsets.bottom;
final currentVisible = bottomHeight != 0;
// save
if (!_keyboardVisible && currentVisible) {
_saveHeight = position.pos.dy;
}
// reset
if (_lastBottomHeight > 0 && bottomHeight == 0) {
setState(() {
position.update(Offset(position.pos.dx, _saveHeight));
});
}
// onKeyboardVisible
if (_keyboardVisible && currentVisible) {
final sumHeight = bottomHeight + _height;
final contextHeight = MediaQuery.of(context).size.height;
if (sumHeight + position.pos.dy > contextHeight) {
final y = contextHeight - sumHeight;
setState(() {
position.update(Offset(position.pos.dx, y));
});
}
}
_keyboardVisible = currentVisible;
_lastBottomHeight = bottomHeight;
}
@override
void initState() {
super.initState();
position.tryAdjust(_width, _height, 1);
}
@override
Widget build(BuildContext context) {
checkKeyboard();
return Stack(
children: [
Positioned(
left: position.pos.dx,
top: position.pos.dy,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
position.update(position.pos + details.delta);
});
_chatModel?.setChatWindowPosition(position.pos);
},
child: Material(
child: Container(
width: _width,
height: _height,
decoration:
BoxDecoration(border: Border.all(color: MyTheme.border)),
child: widget.builder(context),
),
),
),
),
],
);
}
}
class QualityMonitor extends StatelessWidget {
final QualityMonitorModel qualityMonitorModel;
QualityMonitor(this.qualityMonitorModel);
Widget _row(String info, String? value, {Color? rightColor}) {
return Row(
children: [
Expanded(
flex: 8,
child: AutoSizeText(info,
style: TextStyle(color: Color.fromARGB(255, 210, 210, 210)),
textAlign: TextAlign.right,
maxLines: 1)),
Spacer(flex: 1),
Expanded(
flex: 8,
child: AutoSizeText(value ?? '',
style: TextStyle(color: rightColor ?? Colors.white),
maxLines: 1)),
],
);
}
@override
Widget build(BuildContext context) => ChangeNotifierProvider.value(
value: qualityMonitorModel,
child: Consumer<QualityMonitorModel>(
builder: (context, qualityMonitorModel, child) => qualityMonitorModel
.show
? Container(
constraints: BoxConstraints(maxWidth: 200),
padding: const EdgeInsets.all(8),
color: MyTheme.canvasColor.withAlpha(150),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_row("Speed", qualityMonitorModel.data.speed ?? '-'),
_row("FPS", qualityMonitorModel.data.fps ?? '-'),
// let delay be 0 if fps is 0
_row(
"Delay",
"${qualityMonitorModel.data.delay == null ? '-' : (qualityMonitorModel.data.fps ?? "").replaceAll(' ', '').replaceAll('0', '').isEmpty ? 0 : qualityMonitorModel.data.delay}ms",
rightColor: Colors.green),
_row("Target Bitrate",
"${qualityMonitorModel.data.targetBitrate ?? '-'}kb"),
_row(
"Codec", qualityMonitorModel.data.codecFormat ?? '-'),
_row("Chroma", qualityMonitorModel.data.chroma ?? '-'),
],
),
)
: const SizedBox.shrink()));
}
class BlockableOverlayState extends OverlayKeyState {
final _middleBlocked = false.obs;
VoidCallback? onMiddleBlockedClick; // to-do use listener
RxBool get middleBlocked => _middleBlocked;
void addMiddleBlockedListener(void Function(bool) cb) {
_middleBlocked.listen(cb);
}
void setMiddleBlocked(bool blocked) {
if (blocked != _middleBlocked.value) {
_middleBlocked.value = blocked;
}
}
void applyFfi(FFI ffi) {
ffi.dialogManager.setOverlayState(this);
ffi.chatModel.setOverlayState(this);
// make remote page penetrable automatically, effective for chat over remote
onMiddleBlockedClick = () {
setMiddleBlocked(false);
};
}
}
class BlockableOverlay extends StatelessWidget {
final Widget underlying;
final List<OverlayEntry>? upperLayer;
final BlockableOverlayState state;
BlockableOverlay(
{required this.underlying, required this.state, this.upperLayer});
@override
Widget build(BuildContext context) {
final initialEntries = [
OverlayEntry(builder: (_) => underlying),
/// middle layer
OverlayEntry(
builder: (context) => Obx(() => Listener(
onPointerDown: (_) {
state.onMiddleBlockedClick?.call();
},
child: Container(
color:
state.middleBlocked.value ? Colors.transparent : null)))),
];
if (upperLayer != null) {
initialEntries.addAll(upperLayer!);
}
/// set key
return Overlay(key: state.key, initialEntries: initialEntries);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+598
View File
@@ -0,0 +1,598 @@
import 'dart:async';
import 'dart:collection';
import 'package:dynamic_layouts/dynamic_layouts.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/ab_model.dart';
import 'package:flutter_hbb/models/peer_tab_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:visibility_detector/visibility_detector.dart';
import 'package:window_manager/window_manager.dart';
import '../../common.dart';
import '../../models/peer_model.dart';
import '../../models/platform_model.dart';
import 'peer_card.dart';
typedef PeerFilter = bool Function(Peer peer);
typedef PeerCardBuilder = Widget Function(Peer peer);
class PeerSortType {
static const String remoteId = 'Remote ID';
static const String remoteHost = 'Remote Host';
static const String username = 'Username';
static const String status = 'Status';
static List<String> values = [
PeerSortType.remoteId,
PeerSortType.remoteHost,
PeerSortType.username,
PeerSortType.status
];
}
class LoadEvent {
static const String recent = 'load_recent_peers';
static const String favorite = 'load_fav_peers';
static const String lan = 'load_lan_peers';
static const String addressBook = 'load_address_book_peers';
static const String group = 'load_group_peers';
}
class PeersModelName {
static const String recent = 'recent peer';
static const String favorite = 'fav peer';
static const String lan = 'discovered peer';
static const String addressBook = 'address book peer';
static const String group = 'group peer';
}
/// for peer search text, global obs value
final peerSearchText = "".obs;
/// for peer sort, global obs value
RxString? _peerSort;
RxString get peerSort {
_peerSort ??= bind.getLocalFlutterOption(k: kOptionPeerSorting).obs;
return _peerSort!;
}
// list for listener
RxList<RxString> get obslist => [peerSearchText, peerSort].obs;
final peerSearchTextController =
TextEditingController(text: peerSearchText.value);
class _PeersView extends StatefulWidget {
final Peers peers;
final PeerFilter? peerFilter;
final PeerCardBuilder peerCardBuilder;
final PeerTabIndex peerTabIndex;
const _PeersView(
{required this.peers,
required this.peerCardBuilder,
required this.peerTabIndex,
this.peerFilter,
Key? key})
: super(key: key);
@override
_PeersViewState createState() => _PeersViewState();
}
/// State for the peer widget.
class _PeersViewState extends State<_PeersView>
with WindowListener, WidgetsBindingObserver {
static const int _maxQueryCount = 3;
final HashMap<String, String> _emptyMessages = HashMap.from({
LoadEvent.recent: 'empty_recent_tip',
LoadEvent.favorite: 'empty_favorite_tip',
LoadEvent.lan: 'empty_lan_tip',
LoadEvent.addressBook: 'empty_address_book_tip',
});
final space = (isDesktop || isWebDesktop) ? 12.0 : 8.0;
final _curPeers = <String>{};
var _lastChangeTime = DateTime.now();
var _lastQueryPeers = <String>{};
var _lastQueryTime = DateTime.now();
var _lastWindowRestoreTime = DateTime.now();
var _queryCount = 0;
var _exit = false;
bool _isActive = true;
final _scrollController = ScrollController();
_PeersViewState() {
_startCheckOnlines();
}
@override
void initState() {
windowManager.addListener(this);
WidgetsBinding.instance.addObserver(this);
super.initState();
}
@override
void dispose() {
windowManager.removeListener(this);
WidgetsBinding.instance.removeObserver(this);
_exit = true;
super.dispose();
}
@override
void onWindowFocus() {
_queryCount = 0;
_isActive = true;
}
@override
void onWindowBlur() {
// We need this comparison because window restore (on Windows) also triggers `onWindowBlur()`.
// Maybe it's a bug of the window manager, but the source code seems to be correct.
//
// Although `onWindowRestore()` is called after `onWindowBlur()` in my test,
// we need the following comparison to ensure that `_isActive` is true in the end.
if (isWindows &&
DateTime.now().difference(_lastWindowRestoreTime) <
const Duration(milliseconds: 300)) {
return;
}
_queryCount = _maxQueryCount;
_isActive = false;
}
@override
void onWindowRestore() {
// Window restore (on MacOS and Linux) also triggers `onWindowFocus()`.
// But on Windows, it triggers `onWindowBlur()`, mybe it's a bug of the window manager.
if (!isWindows) return;
_queryCount = 0;
_isActive = true;
_lastWindowRestoreTime = DateTime.now();
}
@override
void onWindowMinimize() {
// Window minimize also triggers `onWindowBlur()`.
}
// This function is required for mobile.
// `onWindowFocus` works fine for desktop.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (isDesktop || isWebDesktop) return;
if (state == AppLifecycleState.resumed) {
_isActive = true;
_queryCount = 0;
} else if (state == AppLifecycleState.inactive) {
_isActive = false;
}
}
@override
Widget build(BuildContext context) {
// We should avoid too many rebuilds. MacOS(m1, 14.6.1) on Flutter 3.19.6.
// Continious rebuilds of `ChangeNotifierProvider` will cause memory leak.
// Simple demo can reproduce this issue.
return ChangeNotifierProvider<Peers>.value(
value: widget.peers,
child: Consumer<Peers>(builder: (context, peers, child) {
if (peers.peers.isEmpty) {
gFFI.peerTabModel.setCurrentTabCachedPeers([]);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.sentiment_very_dissatisfied_rounded,
color: Theme.of(context).tabBarTheme.labelColor,
size: 40,
).paddingOnly(bottom: 10),
Text(
translate(
_emptyMessages[widget.peers.loadEvent] ?? 'Empty',
),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).tabBarTheme.labelColor,
),
),
],
),
);
} else {
return _buildPeersView(peers);
}
}),
);
}
onVisibilityChanged(VisibilityInfo info) {
final peerId = _peerId((info.key as ValueKey).value);
if (info.visibleFraction > 0.00001) {
_curPeers.add(peerId);
} else {
_curPeers.remove(peerId);
}
_lastChangeTime = DateTime.now();
}
String _cardId(String id) => widget.peers.name + id;
String _peerId(String cardId) => cardId.replaceAll(widget.peers.name, '');
Widget _buildPeersView(Peers peers) {
final updateEvent = peers.event;
final body = ObxValue<RxList>((filters) {
return FutureBuilder<List<Peer>>(
builder: (context, snapshot) {
if (snapshot.hasData) {
var peers = snapshot.data!;
if (peers.length > 1000) peers = peers.sublist(0, 1000);
gFFI.peerTabModel.setCurrentTabCachedPeers(peers);
buildOnePeer(Peer peer, bool isPortrait) {
final visibilityChild = VisibilityDetector(
key: ValueKey(_cardId(peer.id)),
onVisibilityChanged: onVisibilityChanged,
child: widget.peerCardBuilder(peer),
);
// `Provider.of<PeerTabModel>(context)` will causes infinete loop.
// Because `gFFI.peerTabModel.setCurrentTabCachedPeers(peers)` will trigger `notifyListeners()`.
//
// No need to listen the currentTab change event.
// Because the currentTab change event will trigger the peers change event,
// and the peers change event will trigger _buildPeersView().
return !isPortrait
? Obx(() => peerCardUiType.value == PeerUiType.list
? Container(height: 45, child: visibilityChild)
: peerCardUiType.value == PeerUiType.grid
? SizedBox(
width: 220, height: 140, child: visibilityChild)
: SizedBox(
width: 220, height: 42, child: visibilityChild))
: Container(child: visibilityChild);
}
// We should avoid too many rebuilds. Win10(Some machines) on Flutter 3.19.6.
// Continious rebuilds of `ListView.builder` will cause memory leak.
// Simple demo can reproduce this issue.
final Widget child = Obx(() => stateGlobal.isPortrait.isTrue
? ListView.builder(
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index], true).marginOnly(
top: index == 0 ? 0 : space / 2, bottom: space / 2);
},
)
: peerCardUiType.value == PeerUiType.list
? ListView.builder(
controller: _scrollController,
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index], false).marginOnly(
right: space,
top: index == 0 ? 0 : space / 2,
bottom: space / 2);
},
)
: DynamicGridView.builder(
gridDelegate: SliverGridDelegateWithWrapping(
mainAxisSpacing: space / 2,
crossAxisSpacing: space),
itemCount: peers.length,
itemBuilder: (BuildContext context, int index) {
return buildOnePeer(peers[index], false);
}));
if (updateEvent == UpdateEvent.load) {
_curPeers.clear();
_curPeers.addAll(peers.map((e) => e.id));
_queryOnlines(true);
}
return child;
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
future: matchPeers(filters[0].value, filters[1].value, peers.peers),
);
}, obslist);
return body;
}
var _queryInterval = const Duration(seconds: 20);
void _startCheckOnlines() {
() async {
final p = await bind.mainIsUsingPublicServer();
if (!p) {
_queryInterval = const Duration(seconds: 6);
}
while (!_exit) {
final now = DateTime.now();
if (!setEquals(_curPeers, _lastQueryPeers)) {
if (now.difference(_lastChangeTime) > const Duration(seconds: 1)) {
_queryOnlines(false);
}
} else {
final skipIfIsWeb =
isWeb && !(stateGlobal.isWebVisible && stateGlobal.isInMainPage);
final skipIfMobile =
(isAndroid || isIOS) && !stateGlobal.isInMainPage;
final skipIfNotActive = skipIfIsWeb || skipIfMobile || !_isActive;
if (!skipIfNotActive && (_queryCount < _maxQueryCount || !p)) {
if (now.difference(_lastQueryTime) >= _queryInterval) {
if (_curPeers.isNotEmpty) {
bind.queryOnlines(ids: _curPeers.toList(growable: false));
_lastQueryTime = DateTime.now();
_queryCount += 1;
}
}
}
}
await Future.delayed(const Duration(milliseconds: 300));
}
}();
}
_queryOnlines(bool isLoadEvent) {
if (_curPeers.isNotEmpty) {
bind.queryOnlines(ids: _curPeers.toList(growable: false));
_queryCount = 0;
}
_lastQueryPeers = {..._curPeers};
if (isLoadEvent) {
_lastChangeTime = DateTime.now();
} else {
_lastQueryTime = DateTime.now().subtract(_queryInterval);
}
}
Future<List<Peer>>? matchPeers(
String searchText, String sortedBy, List<Peer> peers) async {
if (widget.peerFilter != null) {
peers = peers.where((peer) => widget.peerFilter!(peer)).toList();
}
// fallback to id sorting
if (!PeerSortType.values.contains(sortedBy)) {
sortedBy = PeerSortType.remoteId;
bind.setLocalFlutterOption(
k: kOptionPeerSorting,
v: sortedBy,
);
}
if (widget.peers.loadEvent != LoadEvent.recent) {
switch (sortedBy) {
case PeerSortType.remoteId:
peers.sort((p1, p2) => p1.getId().compareTo(p2.getId()));
break;
case PeerSortType.remoteHost:
peers.sort((p1, p2) =>
p1.hostname.toLowerCase().compareTo(p2.hostname.toLowerCase()));
break;
case PeerSortType.username:
peers.sort((p1, p2) =>
p1.username.toLowerCase().compareTo(p2.username.toLowerCase()));
break;
case PeerSortType.status:
peers.sort((p1, p2) => p1.online ? -1 : 1);
break;
}
}
searchText = searchText.trim();
if (searchText.isEmpty) {
return peers;
}
searchText = searchText.toLowerCase();
final matches = await Future.wait(
peers.map((peer) => matchPeer(searchText, peer, widget.peerTabIndex)));
final filteredList = List<Peer>.empty(growable: true);
for (var i = 0; i < peers.length; i++) {
if (matches[i]) {
filteredList.add(peers[i]);
}
}
return filteredList;
}
}
abstract class BasePeersView extends StatelessWidget {
final PeerTabIndex peerTabIndex;
final PeerFilter? peerFilter;
final PeerCardBuilder peerCardBuilder;
const BasePeersView({
Key? key,
required this.peerTabIndex,
this.peerFilter,
required this.peerCardBuilder,
}) : super(key: key);
@override
Widget build(BuildContext context) {
Peers peers;
switch (peerTabIndex) {
case PeerTabIndex.recent:
peers = gFFI.recentPeersModel;
break;
case PeerTabIndex.fav:
peers = gFFI.favoritePeersModel;
break;
case PeerTabIndex.lan:
peers = gFFI.lanPeersModel;
break;
case PeerTabIndex.ab:
peers = gFFI.abModel.peersModel;
break;
case PeerTabIndex.group:
peers = gFFI.groupModel.peersModel;
break;
}
return _PeersView(
peers: peers,
peerFilter: peerFilter,
peerCardBuilder: peerCardBuilder,
peerTabIndex: peerTabIndex);
}
}
class RecentPeersView extends BasePeersView {
RecentPeersView(
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
peerTabIndex: PeerTabIndex.recent,
peerCardBuilder: (Peer peer) => RecentPeerCard(
peer: peer,
menuPadding: menuPadding,
),
);
@override
Widget build(BuildContext context) {
final widget = super.build(context);
bind.mainLoadRecentPeers();
return widget;
}
}
class FavoritePeersView extends BasePeersView {
FavoritePeersView(
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
peerTabIndex: PeerTabIndex.fav,
peerCardBuilder: (Peer peer) => FavoritePeerCard(
peer: peer,
menuPadding: menuPadding,
),
);
@override
Widget build(BuildContext context) {
final widget = super.build(context);
bind.mainLoadFavPeers();
return widget;
}
}
class DiscoveredPeersView extends BasePeersView {
DiscoveredPeersView(
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
peerTabIndex: PeerTabIndex.lan,
peerCardBuilder: (Peer peer) => DiscoveredPeerCard(
peer: peer,
menuPadding: menuPadding,
),
);
@override
Widget build(BuildContext context) {
final widget = super.build(context);
bind.mainLoadLanPeers();
bind.mainDiscover();
return widget;
}
}
class AddressBookPeersView extends BasePeersView {
AddressBookPeersView(
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
peerTabIndex: PeerTabIndex.ab,
peerFilter: (Peer peer) =>
_hitTag(gFFI.abModel.selectedTags, peer.tags),
peerCardBuilder: (Peer peer) => AddressBookPeerCard(
peer: peer,
menuPadding: menuPadding,
),
);
static bool _hitTag(List<dynamic> selectedTags, List<dynamic> idents) {
if (selectedTags.isEmpty) {
return true;
}
// The result of a no-tag union with normal tags, still allows normal tags to perform union or intersection operations.
final selectedNormalTags =
selectedTags.where((tag) => tag != kUntagged).toList();
if (selectedTags.contains(kUntagged)) {
if (idents.isEmpty) return true;
if (selectedNormalTags.isEmpty) return false;
}
if (gFFI.abModel.filterByIntersection.value) {
for (final tag in selectedNormalTags) {
if (!idents.contains(tag)) {
return false;
}
}
return true;
} else {
for (final tag in selectedNormalTags) {
if (idents.contains(tag)) {
return true;
}
}
return false;
}
}
}
class MyGroupPeerView extends BasePeersView {
MyGroupPeerView(
{Key? key, EdgeInsets? menuPadding, ScrollController? scrollController})
: super(
key: key,
peerTabIndex: PeerTabIndex.group,
peerFilter: filter,
peerCardBuilder: (Peer peer) => MyGroupPeerCard(
peer: peer,
menuPadding: menuPadding,
),
);
static bool filter(Peer peer) {
final model = gFFI.groupModel;
if (model.searchAccessibleItemNameText.isNotEmpty) {
final text = model.searchAccessibleItemNameText.value.toLowerCase();
final searchPeersOfUser = model.users.any((user) =>
user.name == peer.loginName &&
(user.name.toLowerCase().contains(text) ||
user.displayNameOrName.toLowerCase().contains(text)));
final searchPeersOfDeviceGroup =
peer.device_group_name.toLowerCase().contains(text) &&
model.deviceGroups.any((g) => g.name == peer.device_group_name);
if (!searchPeersOfUser && !searchPeersOfDeviceGroup) {
return false;
}
}
if (model.selectedAccessibleItemName.isNotEmpty) {
if (model.isSelectedDeviceGroup.value) {
if (model.selectedAccessibleItemName.value != peer.device_group_name) {
return false;
}
} else {
if (model.selectedAccessibleItemName.value != peer.loginName) {
return false;
}
}
}
return true;
}
}
@@ -0,0 +1,692 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/input_model.dart';
import './gestures.dart';
class RawKeyFocusScope extends StatelessWidget {
final FocusNode? focusNode;
final ValueChanged<bool>? onFocusChange;
final InputModel inputModel;
final Widget child;
RawKeyFocusScope({
this.focusNode,
this.onFocusChange,
required this.inputModel,
required this.child,
});
@override
Widget build(BuildContext context) {
// https://github.com/flutter/flutter/issues/154053
final useRawKeyEvents = isLinux && !isWeb;
// FIXME: On Windows, `AltGr` will generate `Alt` and `Control` key events,
// while `Alt` and `Control` are separated key events for en-US input method.
return FocusScope(
autofocus: true,
child: Focus(
autofocus: true,
canRequestFocus: true,
focusNode: focusNode,
onFocusChange: onFocusChange,
onKey: useRawKeyEvents
? (FocusNode data, RawKeyEvent event) =>
inputModel.handleRawKeyEvent(event)
: null,
onKeyEvent: useRawKeyEvents
? null
: (FocusNode node, KeyEvent event) =>
inputModel.handleKeyEvent(event),
child: child));
}
}
// For virtual mouse when using the mouse mode on mobile.
// Special hold-drag mode: one finger holds a button (left/right button), another finger pans.
// This flag is to override the scale gesture to a pan gesture.
bool isSpecialHoldDragActive = false;
// Cache the last focal point to calculate deltas in special hold-drag mode.
Offset _lastSpecialHoldDragFocalPoint = Offset.zero;
class RawTouchGestureDetectorRegion extends StatefulWidget {
final Widget child;
final FFI ffi;
final bool isCamera;
late final InputModel inputModel = ffi.inputModel;
late final FfiModel ffiModel = ffi.ffiModel;
RawTouchGestureDetectorRegion({
required this.child,
required this.ffi,
this.isCamera = false,
});
@override
State<RawTouchGestureDetectorRegion> createState() =>
_RawTouchGestureDetectorRegionState();
}
/// touchMode only:
/// LongPress -> right click
/// OneFingerPan -> start/end -> left down start/end
/// onDoubleTapDown -> move to
/// onLongPressDown => move to
///
/// mouseMode only:
/// DoubleFiner -> right click
/// HoldDrag -> left drag
class _RawTouchGestureDetectorRegionState
extends State<RawTouchGestureDetectorRegion> {
Offset _cacheLongPressPosition = Offset(0, 0);
// Timestamp of the last long press event.
int _cacheLongPressPositionTs = 0;
double _mouseScrollIntegral = 0; // mouse scroll speed controller
double _scale = 1;
// Workaround tap down event when two fingers are used to scale(mobile)
TapDownDetails? _lastTapDownDetails;
PointerDeviceKind? lastDeviceKind;
// For touch mode, onDoubleTap
// `onDoubleTap()` does not provide the position of the tap event.
Offset _lastPosOfDoubleTapDown = Offset.zero;
bool _touchModePanStarted = false;
Offset _doubleFinerTapPosition = Offset.zero;
// For mouse mode, we need to block the events when the cursor is in a blocked area.
// So we need to cache the last tap down position.
Offset? _lastTapDownPositionForMouseMode;
// Cache global position for onTap (which lacks position info).
Offset? _lastTapDownGlobalPosition;
FFI get ffi => widget.ffi;
FfiModel get ffiModel => widget.ffiModel;
InputModel get inputModel => widget.inputModel;
bool get handleTouch => (isDesktop || isWebDesktop) || ffiModel.touchMode;
SessionID get sessionId => ffi.sessionId;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
child: widget.child,
gestures: makeGestures(context),
);
}
bool isNotTouchBasedDevice() {
return !kTouchBasedDeviceKinds.contains(lastDeviceKind);
}
// Mobile, mouse mode.
// Check if should block the mouse tap event (`_lastTapDownPositionForMouseMode`).
bool shouldBlockMouseModeEvent() {
return _lastTapDownPositionForMouseMode != null &&
ffi.cursorModel.shouldBlock(_lastTapDownPositionForMouseMode!.dx,
_lastTapDownPositionForMouseMode!.dy);
}
onTapDown(TapDownDetails d) async {
lastDeviceKind = d.kind;
_lastTapDownGlobalPosition = d.globalPosition;
if (isNotTouchBasedDevice()) {
return;
}
if (handleTouch) {
_lastPosOfDoubleTapDown = d.localPosition;
// Desktop or mobile "Touch mode"
_lastTapDownDetails = d;
} else {
_lastTapDownPositionForMouseMode = d.localPosition;
}
}
onTapUp(TapUpDetails d) async {
final TapDownDetails? lastTapDownDetails = _lastTapDownDetails;
_lastTapDownDetails = null;
if (isNotTouchBasedDevice()) {
return;
}
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
if (inputModel.shouldIgnoreTouchTap(d.globalPosition)) {
return;
}
if (handleTouch) {
final isMoved =
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
if (isMoved) {
// If pan already handled 'down', don't send it again.
if (lastTapDownDetails != null && !_touchModePanStarted) {
await inputModel.tapDown(MouseButtons.left);
}
await inputModel.tapUp(MouseButtons.left);
}
}
}
onTap() async {
if (isNotTouchBasedDevice()) {
return;
}
// Filter duplicate touch tap events on iOS (Magic Mouse issue).
final lastPos = _lastTapDownGlobalPosition;
if (lastPos != null && inputModel.shouldIgnoreTouchTap(lastPos)) {
return;
}
if (!handleTouch) {
// Cannot use `_lastTapDownDetails` because Flutter calls `onTapUp` before `onTap`, clearing the cached details.
// Using `_lastTapDownPositionForMouseMode` instead.
if (shouldBlockMouseModeEvent()) {
return;
}
// Mobile, "Mouse mode"
await inputModel.tap(MouseButtons.left);
}
}
onDoubleTapDown(TapDownDetails d) async {
lastDeviceKind = d.kind;
if (isNotTouchBasedDevice()) {
return;
}
if (handleTouch) {
_lastPosOfDoubleTapDown = d.localPosition;
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
} else {
_lastTapDownPositionForMouseMode = d.localPosition;
}
}
onDoubleTap() async {
if (isNotTouchBasedDevice()) {
return;
}
if (ffiModel.touchMode && ffi.cursorModel.lastIsBlocked) {
return;
}
if (handleTouch &&
!ffi.cursorModel.isInRemoteRect(_lastPosOfDoubleTapDown)) {
return;
}
// Check if the position is in a blocked area when using the mouse mode.
if (!handleTouch) {
if (shouldBlockMouseModeEvent()) {
return;
}
}
await inputModel.tap(MouseButtons.left);
await inputModel.tap(MouseButtons.left);
}
onLongPressDown(LongPressDownDetails d) async {
lastDeviceKind = d.kind;
if (isNotTouchBasedDevice()) {
return;
}
if (handleTouch) {
_lastPosOfDoubleTapDown = d.localPosition;
_cacheLongPressPosition = d.localPosition;
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
return;
}
_cacheLongPressPositionTs = DateTime.now().millisecondsSinceEpoch;
if (ffiModel.isPeerMobile) {
await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
await inputModel.tapDown(MouseButtons.left);
}
} else {
_lastTapDownPositionForMouseMode = d.localPosition;
}
}
onLongPressUp() async {
if (isNotTouchBasedDevice()) {
return;
}
if (handleTouch) {
await inputModel.tapUp(MouseButtons.left);
}
}
// for mobiles
onLongPress() async {
if (isNotTouchBasedDevice()) {
return;
}
if (!ffi.ffiModel.isPeerMobile) {
if (handleTouch) {
final isMoved = await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
if (!isMoved) {
return;
}
} else {
if (shouldBlockMouseModeEvent()) {
return;
}
}
await inputModel.tap(MouseButtons.right);
} else {
// It's better to send a message to tell the controlled device that the long press event is triggered.
// We're now using a `TimerTask` in `InputService.kt` to decide whether to trigger the long press event.
// It's not accurate and it's better to use the same detection logic in the controlling side.
}
}
onLongPressMoveUpdate(LongPressMoveUpdateDetails d) async {
if (!ffiModel.isPeerMobile || isNotTouchBasedDevice()) {
return;
}
if (handleTouch) {
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
return;
}
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
}
}
onDoubleFinerTapDown(TapDownDetails d) async {
lastDeviceKind = d.kind;
if (isNotTouchBasedDevice()) {
return;
}
_doubleFinerTapPosition = d.localPosition;
// ignore for desktop and mobile
}
onDoubleFinerTap(TapDownDetails d) async {
lastDeviceKind = d.kind;
if (isNotTouchBasedDevice()) {
return;
}
// mobile mouse mode or desktop touch screen
final isMobileMouseMode = isMobile && !ffiModel.touchMode;
// We can't use `d.localPosition` here because it's always (0, 0) on desktop.
final isDesktopInRemoteRect = (isDesktop || isWebDesktop) &&
ffi.cursorModel.isInRemoteRect(_doubleFinerTapPosition);
if (isMobileMouseMode || isDesktopInRemoteRect) {
await inputModel.tap(MouseButtons.right);
}
}
onHoldDragStart(DragStartDetails d) async {
lastDeviceKind = d.kind;
if (isNotTouchBasedDevice()) {
return;
}
if (!handleTouch) {
if (isSpecialHoldDragActive) return;
await inputModel.sendMouse('down', MouseButtons.left);
}
}
onHoldDragUpdate(DragUpdateDetails d) async {
if (isNotTouchBasedDevice()) {
return;
}
if (!handleTouch) {
if (isSpecialHoldDragActive) return;
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
}
}
onHoldDragEnd(DragEndDetails d) async {
if (isNotTouchBasedDevice()) {
return;
}
if (!handleTouch) {
await inputModel.sendMouse('up', MouseButtons.left);
}
}
onOneFingerPanStart(BuildContext context, DragStartDetails d) async {
final TapDownDetails? lastTapDownDetails = _lastTapDownDetails;
_lastTapDownDetails = null;
lastDeviceKind = d.kind ?? lastDeviceKind;
if (isNotTouchBasedDevice()) {
return;
}
if (handleTouch) {
if (lastTapDownDetails != null) {
await ffi.cursorModel.move(lastTapDownDetails.localPosition.dx,
lastTapDownDetails.localPosition.dy);
}
if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
return;
}
if (!ffi.cursorModel.isInRemoteRect(d.localPosition)) {
return;
}
_touchModePanStarted = true;
if (isDesktop || isWebDesktop) {
ffi.cursorModel.trySetRemoteWindowCoords();
}
// Workaround for the issue that the first pan event is sent a long time after the start event.
// If the time interval between the start event and the first pan event is less than 500ms,
// we consider to use the long press position as the start position.
//
// TODO: We should find a better way to send the first pan event as soon as possible.
if (DateTime.now().millisecondsSinceEpoch - _cacheLongPressPositionTs <
500) {
await ffi.cursorModel
.move(_cacheLongPressPosition.dx, _cacheLongPressPosition.dy);
}
// In relative mouse mode, skip mouse down - only send movement via sendMobileRelativeMouseMove
if (!inputModel.relativeMouseMode.value) {
await inputModel.sendMouse('down', MouseButtons.left);
}
await ffi.cursorModel.move(d.localPosition.dx, d.localPosition.dy);
} else {
final offset = ffi.cursorModel.offset;
final cursorX = offset.dx;
final cursorY = offset.dy;
final visible =
ffi.cursorModel.getVisibleRect().inflate(1); // extend edges
final size = MediaQueryData.fromView(View.of(context)).size;
if (!visible.contains(Offset(cursorX, cursorY))) {
await ffi.cursorModel.move(size.width / 2, size.height / 2);
}
}
}
onOneFingerPanUpdate(DragUpdateDetails d) async {
if (isNotTouchBasedDevice()) {
return;
}
if (ffi.cursorModel.shouldBlock(d.localPosition.dx, d.localPosition.dy)) {
return;
}
if (handleTouch && !_touchModePanStarted) {
return;
}
// In relative mouse mode, send delta directly without position tracking.
if (inputModel.relativeMouseMode.value) {
await inputModel.sendMobileRelativeMouseMove(d.delta.dx, d.delta.dy);
} else {
await ffi.cursorModel.updatePan(d.delta, d.localPosition, handleTouch);
}
}
onOneFingerPanEnd(DragEndDetails d) async {
_touchModePanStarted = false;
if (isNotTouchBasedDevice()) {
return;
}
if (isDesktop || isWebDesktop) {
ffi.cursorModel.clearRemoteWindowCoords();
}
if (handleTouch) {
// In relative mouse mode, skip mouse up - matches the skipped mouse down in onOneFingerPanStart
if (!inputModel.relativeMouseMode.value) {
await inputModel.sendMouse('up', MouseButtons.left);
}
}
}
// Reset `_touchModePanStarted` if the one-finger pan gesture is cancelled
// or rejected by the gesture arena. Without this, the flag can remain
// stuck in the "started" state and cause issues such as the Magic Mouse
// double-click problem on iPad with magic mouse.
onOneFingerPanCancel() {
_touchModePanStarted = false;
}
// scale + pan event
onTwoFingerScaleStart(ScaleStartDetails d) {
_lastTapDownDetails = null;
if (isNotTouchBasedDevice()) {
return;
}
if (isSpecialHoldDragActive) {
// Initialize the last focal point to calculate deltas manually.
_lastSpecialHoldDragFocalPoint = d.focalPoint;
}
}
onTwoFingerScaleUpdate(ScaleUpdateDetails d) async {
if (isNotTouchBasedDevice()) {
return;
}
// If in special drag mode, perform a pan instead of a scale.
if (isSpecialHoldDragActive) {
// Calculate delta manually to avoid the jumpy behavior.
final delta = d.focalPoint - _lastSpecialHoldDragFocalPoint;
_lastSpecialHoldDragFocalPoint = d.focalPoint;
await ffi.cursorModel.updatePan(delta * 2.0, d.focalPoint, handleTouch);
return;
}
if ((isDesktop || isWebDesktop)) {
final scale = ((d.scale - _scale) * 1000).toInt();
_scale = d.scale;
if (scale != 0) {
if (widget.isCamera) return;
await bind.sessionSendPointer(
sessionId: sessionId,
msg: json.encode(
PointerEventToRust(kPointerEventKindTouch, 'scale', scale)
.toJson()));
}
} else {
// mobile
ffi.canvasModel.updateScale(d.scale / _scale, d.focalPoint);
_scale = d.scale;
ffi.canvasModel.panX(d.focalPointDelta.dx);
ffi.canvasModel.panY(d.focalPointDelta.dy);
}
}
onTwoFingerScaleEnd(ScaleEndDetails d) async {
if (isNotTouchBasedDevice()) {
return;
}
if ((isDesktop || isWebDesktop)) {
if (widget.isCamera) return;
await bind.sessionSendPointer(
sessionId: sessionId,
msg: json.encode(
PointerEventToRust(kPointerEventKindTouch, 'scale', 0).toJson()));
} else {
// mobile
_scale = 1;
// No idea why we need to set the view style to "" here.
// bind.sessionSetViewStyle(sessionId: sessionId, value: "");
}
if (!isSpecialHoldDragActive) {
await inputModel.sendMouse('up', MouseButtons.left);
}
}
get onHoldDragCancel => null;
get onThreeFingerVerticalDragUpdate => ffi.ffiModel.isPeerAndroid
? null
: (d) {
_mouseScrollIntegral += d.delta.dy / 4;
if (_mouseScrollIntegral > 1) {
inputModel.scroll(1);
_mouseScrollIntegral = 0;
} else if (_mouseScrollIntegral < -1) {
inputModel.scroll(-1);
_mouseScrollIntegral = 0;
}
};
makeGestures(BuildContext context) {
return <Type, GestureRecognizerFactory>{
// Official
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
..onTap = onTap;
}),
DoubleTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
() => DoubleTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onDoubleTapDown = onDoubleTapDown
..onDoubleTap = onDoubleTap;
}),
LongPressGestureRecognizer:
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onLongPressDown = onLongPressDown
..onLongPressUp = onLongPressUp
..onLongPress = onLongPress
..onLongPressMoveUpdate = onLongPressMoveUpdate;
}),
// Customized
HoldTapMoveGestureRecognizer:
GestureRecognizerFactoryWithHandlers<HoldTapMoveGestureRecognizer>(
() => HoldTapMoveGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
),
(instance) => instance
..onHoldDragStart = onHoldDragStart
..onHoldDragUpdate = onHoldDragUpdate
..onHoldDragCancel = onHoldDragCancel
..onHoldDragEnd = onHoldDragEnd),
DoubleFinerTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<DoubleFinerTapGestureRecognizer>(
() => DoubleFinerTapGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance
..onDoubleFinerTap = onDoubleFinerTap
..onDoubleFinerTapDown = onDoubleFinerTapDown;
}),
CustomTouchGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTouchGestureRecognizer>(
() => CustomTouchGestureRecognizer(
supportedDevices: kTouchBasedDeviceKinds,
), (instance) {
instance.onOneFingerPanStart =
(DragStartDetails d) => onOneFingerPanStart(context, d);
instance
..onOneFingerPanUpdate = onOneFingerPanUpdate
..onOneFingerPanEnd = onOneFingerPanEnd
..onOneFingerPanCancel = onOneFingerPanCancel
..onTwoFingerScaleStart = onTwoFingerScaleStart
..onTwoFingerScaleUpdate = onTwoFingerScaleUpdate
..onTwoFingerScaleEnd = onTwoFingerScaleEnd
..onThreeFingerVerticalDragUpdate = onThreeFingerVerticalDragUpdate;
}),
};
}
}
class RawPointerMouseRegion extends StatelessWidget {
final InputModel inputModel;
final Widget child;
final MouseCursor? cursor;
final PointerEnterEventListener? onEnter;
final PointerExitEventListener? onExit;
final PointerDownEventListener? onPointerDown;
final PointerUpEventListener? onPointerUp;
RawPointerMouseRegion({
this.onEnter,
this.onExit,
this.cursor,
this.onPointerDown,
this.onPointerUp,
required this.inputModel,
required this.child,
});
@override
Widget build(BuildContext context) {
return Listener(
onPointerHover: inputModel.onPointHoverImage,
onPointerDown: (evt) {
onPointerDown?.call(evt);
inputModel.onPointDownImage(evt);
},
onPointerUp: (evt) {
onPointerUp?.call(evt);
inputModel.onPointUpImage(evt);
},
onPointerMove: inputModel.onPointMoveImage,
onPointerSignal: inputModel.onPointerSignalImage,
onPointerPanZoomStart: inputModel.onPointerPanZoomStart,
onPointerPanZoomUpdate: inputModel.onPointerPanZoomUpdate,
onPointerPanZoomEnd: inputModel.onPointerPanZoomEnd,
child: MouseRegion(
cursor: inputModel.isViewOnly
? MouseCursor.defer
: (cursor ?? MouseCursor.defer),
onEnter: onEnter,
onExit: onExit,
child: child,
),
);
}
}
class CameraRawPointerMouseRegion extends StatelessWidget {
final InputModel inputModel;
final Widget child;
final PointerEnterEventListener? onEnter;
final PointerExitEventListener? onExit;
final PointerDownEventListener? onPointerDown;
final PointerUpEventListener? onPointerUp;
CameraRawPointerMouseRegion({
this.onEnter,
this.onExit,
this.onPointerDown,
this.onPointerUp,
required this.inputModel,
required this.child,
});
@override
Widget build(BuildContext context) {
return Listener(
onPointerHover: (evt) {
final offset = evt.position;
double x = offset.dx;
double y = max(0.0, offset.dy);
inputModel.handlePointerDevicePos(
kPointerEventKindMouse, x, y, true, kMouseEventTypeDefault);
},
onPointerDown: (evt) {
onPointerDown?.call(evt);
},
onPointerUp: (evt) {
onPointerUp?.call(evt);
},
child: MouseRegion(
cursor: MouseCursor.defer,
onEnter: onEnter,
onExit: onExit,
child: child,
),
);
}
}
@@ -0,0 +1,340 @@
import 'package:debounce_throttle/debounce_throttle.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
customImageQualityWidget(
{required double initQuality,
required double initFps,
required Function(double)? setQuality,
required Function(double)? setFps,
required bool showFps,
required bool showMoreQuality}) {
if (initQuality < kMinQuality ||
initQuality > (showMoreQuality ? kMaxMoreQuality : kMaxQuality)) {
initQuality = kDefaultQuality;
}
if (initFps < kMinFps || initFps > kMaxFps) {
initFps = kDefaultFps;
}
final qualityValue = initQuality.obs;
final fpsValue = initFps.obs;
final RxBool moreQualityChecked = RxBool(qualityValue.value > kMaxQuality);
final debouncerQuality = Debouncer<double>(
Duration(milliseconds: 1000),
onChanged: setQuality,
initialValue: qualityValue.value,
);
final debouncerFps = Debouncer<double>(
Duration(milliseconds: 1000),
onChanged: setFps,
initialValue: fpsValue.value,
);
onMoreChanged(bool? value) {
if (value == null) return;
moreQualityChecked.value = value;
if (!value && qualityValue.value > 100) {
qualityValue.value = 100;
}
debouncerQuality.value = qualityValue.value;
}
return Column(
children: [
Obx(() => Row(
children: [
Expanded(
flex: 3,
child: Slider(
value: qualityValue.value,
min: kMinQuality,
max: moreQualityChecked.value ? kMaxMoreQuality : kMaxQuality,
divisions: moreQualityChecked.value
? ((kMaxMoreQuality - kMinQuality) / 10).round()
: ((kMaxQuality - kMinQuality) / 5).round(),
onChanged: setQuality == null
? null
: (double value) async {
qualityValue.value = value;
debouncerQuality.value = value;
},
),
),
Expanded(
flex: 1,
child: Text(
'${qualityValue.value.round()}%',
style: const TextStyle(fontSize: 15),
)),
Expanded(
flex: isMobile ? 2 : 1,
child: Text(
translate('Bitrate'),
style: const TextStyle(fontSize: 15),
)),
// mobile doesn't have enough space
if (showMoreQuality && !isMobile)
Expanded(
flex: 1,
child: Row(
children: [
Checkbox(
value: moreQualityChecked.value,
onChanged: onMoreChanged,
),
Expanded(
child: Text(translate('More')),
)
],
))
],
)),
if (showMoreQuality && isMobile)
Obx(() => Row(
children: [
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Checkbox(
value: moreQualityChecked.value,
onChanged: onMoreChanged,
),
),
),
Expanded(
child: Text(translate('More')),
)
],
)),
if (showFps)
Obx(() => Row(
children: [
Expanded(
flex: 3,
child: Slider(
value: fpsValue.value,
min: kMinFps,
max: kMaxFps,
divisions: ((kMaxFps - kMinFps) / 5).round(),
onChanged: setFps == null
? null
: (double value) async {
fpsValue.value = value;
debouncerFps.value = value;
},
),
),
Expanded(
flex: 1,
child: Text(
'${fpsValue.value.round()}',
style: const TextStyle(fontSize: 15),
)),
Expanded(
flex: 2,
child: Text(
translate('FPS'),
style: const TextStyle(fontSize: 15),
))
],
)),
],
);
}
customImageQualitySetting() {
final qualityKey = 'custom_image_quality';
final fpsKey = 'custom-fps';
final initQuality =
(double.tryParse(bind.mainGetUserDefaultOption(key: qualityKey)) ??
kDefaultQuality);
final isQuanlityFixed = isOptionFixed(qualityKey);
final initFps =
(double.tryParse(bind.mainGetUserDefaultOption(key: fpsKey)) ??
kDefaultFps);
final isFpsFixed = isOptionFixed(fpsKey);
return customImageQualityWidget(
initQuality: initQuality,
initFps: initFps,
setQuality: isQuanlityFixed
? null
: (v) {
bind.mainSetUserDefaultOption(
key: qualityKey, value: v.toString());
},
setFps: isFpsFixed
? null
: (v) {
bind.mainSetUserDefaultOption(key: fpsKey, value: v.toString());
},
showFps: true,
showMoreQuality: true);
}
List<Widget> ServerConfigImportExportWidgets(
List<TextEditingController> controllers,
List<RxString> errMsgs,
) {
import() {
Clipboard.getData(Clipboard.kTextPlain).then((value) {
importConfig(controllers, errMsgs, value?.text);
});
}
export() {
final text = ServerConfig(
idServer: controllers[0].text.trim(),
relayServer: controllers[1].text.trim(),
apiServer: controllers[2].text.trim(),
key: controllers[3].text.trim())
.encode();
debugPrint("ServerConfig export: $text");
Clipboard.setData(ClipboardData(text: text));
showToast(translate('Export server configuration successfully'));
}
return [
Tooltip(
message: translate('Import server config'),
child: IconButton(
icon: Icon(Icons.paste, color: Colors.grey), onPressed: import),
),
Tooltip(
message: translate('Export Server Config'),
child: IconButton(
icon: Icon(Icons.copy, color: Colors.grey), onPressed: export))
];
}
List<(String, String)> otherDefaultSettings() {
List<(String, String)> v = [
('View Mode', kOptionViewOnly),
if ((isDesktop || isWebDesktop))
('show_monitors_tip', kKeyShowMonitorsToolbar),
if ((isDesktop || isWebDesktop))
('Collapse toolbar', kOptionCollapseToolbar),
('Show remote cursor', kOptionShowRemoteCursor),
('Follow remote cursor', kOptionFollowRemoteCursor),
('Follow remote window focus', kOptionFollowRemoteWindow),
if ((isDesktop || isWebDesktop)) ('Zoom cursor', kOptionZoomCursor),
('Show quality monitor', kOptionShowQualityMonitor),
('Mute', kOptionDisableAudio),
if (isDesktop) ('Enable file copy and paste', kOptionEnableFileCopyPaste),
('Disable clipboard', kOptionDisableClipboard),
('Lock after session end', kOptionLockAfterSessionEnd),
('Privacy mode', kOptionPrivacyMode),
('True color (4:4:4)', kOptionI444),
('Reverse mouse wheel', kKeyReverseMouseWheel),
('swap-left-right-mouse', kOptionSwapLeftRightMouse),
if (isDesktop)
(
'Show displays as individual windows',
kKeyShowDisplaysAsIndividualWindows
),
if (isDesktop)
(
'Use all my displays for the remote session',
kKeyUseAllMyDisplaysForTheRemoteSession
),
('Keep terminal sessions on disconnect', kOptionTerminalPersistent),
];
return v;
}
class TrackpadSpeedWidget extends StatefulWidget {
final SimpleWrapper<int> value;
// If null, no debouncer will be applied.
final Function(int)? onDebouncer;
TrackpadSpeedWidget({Key? key, required this.value, this.onDebouncer});
@override
TrackpadSpeedWidgetState createState() => TrackpadSpeedWidgetState();
}
class TrackpadSpeedWidgetState extends State<TrackpadSpeedWidget> {
final TextEditingController _controller = TextEditingController();
late final Debouncer<int> debouncerSpeed;
set value(int v) => widget.value.value = v;
int get value => widget.value.value;
void updateValue(int newValue) {
setState(() {
value = newValue.clamp(kMinTrackpadSpeed, kMaxTrackpadSpeed);
// Scale the trackpad speed value to a percentage for display purposes.
_controller.text = value.toString();
if (widget.onDebouncer != null) {
debouncerSpeed.setValue(value);
}
});
}
@override
void initState() {
super.initState();
debouncerSpeed = Debouncer<int>(
Duration(milliseconds: 1000),
onChanged: widget.onDebouncer,
initialValue: widget.value.value,
);
}
@override
Widget build(BuildContext context) {
if (_controller.text.isEmpty) {
_controller.text = value.toString();
}
return Row(
children: [
Expanded(
flex: 3,
child: Slider(
value: value.toDouble(),
min: kMinTrackpadSpeed.toDouble(),
max: kMaxTrackpadSpeed.toDouble(),
divisions: ((kMaxTrackpadSpeed - kMinTrackpadSpeed) / 10).round(),
onChanged: (double v) => updateValue(v.round()),
),
),
Expanded(
flex: 1,
child: Row(
children: [
SizedBox(
width: 56,
child: TextField(
controller: _controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
onSubmitted: (text) {
int? v = int.tryParse(text);
if (v != null) {
updateValue(v);
}
},
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
contentPadding:
EdgeInsets.symmetric(vertical: 8.0, horizontal: 12.0),
),
),
).marginOnly(right: 8.0),
Text(
'%',
style: const TextStyle(fontSize: 15),
)
],
)),
],
);
}
}
File diff suppressed because it is too large Load Diff
+695
View File
@@ -0,0 +1,695 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
const int kMaxVirtualDisplayCount = 4;
const int kAllVirtualDisplay = -1;
const double kDesktopRemoteTabBarHeight = 28.0;
const int kInvalidWindowId = -1;
const int kMainWindowId = 0;
const kAllDisplayValue = -1;
const kKeyLegacyMode = 'legacy';
const kKeyMapMode = 'map';
const kKeyTranslateMode = 'translate';
const String kPlatformAdditionsIsWayland = "is_wayland";
const String kPlatformAdditionsHeadless = "headless";
const String kPlatformAdditionsIsInstalled = "is_installed";
const String kPlatformAdditionsIddImpl = "idd_impl";
const String kPlatformAdditionsRustDeskVirtualDisplays =
"rustdesk_virtual_displays";
const String kPlatformAdditionsAmyuniVirtualDisplays =
"amyuni_virtual_displays";
const String kPlatformAdditionsHasFileClipboard = "has_file_clipboard";
const String kPlatformAdditionsSupportedPrivacyModeImpl =
"supported_privacy_mode_impl";
const String kPeerPlatformWindows = "Windows";
const String kPeerPlatformLinux = "Linux";
const String kPeerPlatformMacOS = "Mac OS";
const String kPeerPlatformAndroid = "Android";
const String kPeerPlatformWebDesktop = "WebDesktop";
const double kScrollbarThickness = 12.0;
/// [kAppTypeMain] used by 'Desktop Main Page' , 'Mobile (Client and Server)', "Install Page"
const String kAppTypeMain = "main";
/// [kAppTypeConnectionManager] only for 'Desktop CM Page'
const String kAppTypeConnectionManager = "cm";
const String kAppTypeDesktopRemote = "remote";
const String kAppTypeDesktopFileTransfer = "file transfer";
const String kAppTypeDesktopViewCamera = "view camera";
const String kAppTypeDesktopPortForward = "port forward";
const String kAppTypeDesktopTerminal = "terminal";
const String kWindowMainWindowOnTop = "main_window_on_top";
const String kWindowRefreshCurrentUser = "refresh_current_user";
const String kWindowGetWindowInfo = "get_window_info";
const String kWindowGetScreenList = "get_screen_list";
// This method is not used, maybe it can be removed.
const String kWindowDisableGrabKeyboard = "disable_grab_keyboard";
const String kWindowActionRebuild = "rebuild";
const String kWindowEventHide = "hide";
const String kWindowEventShow = "show";
const String kWindowConnect = "connect";
const String kWindowBumpMouse = "bump_mouse";
const String kWindowEventNewRemoteDesktop = "new_remote_desktop";
const String kWindowEventNewFileTransfer = "new_file_transfer";
const String kWindowEventNewViewCamera = "new_view_camera";
const String kWindowEventNewPortForward = "new_port_forward";
const String kWindowEventNewTerminal = "new_terminal";
const String kWindowEventRestoreTerminalSessions = "restore_terminal_sessions";
const String kWindowEventActiveSession = "active_session";
const String kWindowEventActiveDisplaySession = "active_display_session";
const String kWindowEventGetRemoteList = "get_remote_list";
const String kWindowEventGetSessionIdList = "get_session_id_list";
const String kWindowEventRemoteWindowCoords = "remote_window_coords";
const String kWindowEventSetFullscreen = "set_fullscreen";
const String kWindowEventMoveTabToNewWindow = "move_tab_to_new_window";
const String kWindowEventGetCachedSessionData = "get_cached_session_data";
const String kWindowEventOpenMonitorSession = "open_monitor_session";
const String kOptionViewStyle = "view_style";
const String kOptionScrollStyle = "scroll_style";
const String kOptionEdgeScrollEdgeThickness = "edge-scroll-edge-thickness";
const String kOptionImageQuality = "image_quality";
const String kOptionOpenNewConnInTabs = "enable-open-new-connections-in-tabs";
const String kOptionTextureRender = "use-texture-render";
const String kOptionD3DRender = "allow-d3d-render";
const String kOptionOpenInTabs = "allow-open-in-tabs";
const String kOptionOpenInWindows = "allow-open-in-windows";
const String kOptionForceAlwaysRelay = "force-always-relay";
const String kOptionViewOnly = "view_only";
const String kOptionEnableLanDiscovery = "enable-lan-discovery";
const String kOptionWhitelist = "whitelist";
const String kOptionEnableAbr = "enable-abr";
const String kOptionEnableRecordSession = "enable-record-session";
const String kOptionDirectServer = "direct-server";
const String kOptionDirectAccessPort = "direct-access-port";
const String kOptionAllowAutoDisconnect = "allow-auto-disconnect";
const String kOptionAutoDisconnectTimeout = "auto-disconnect-timeout";
const String kOptionEnableHwcodec = "enable-hwcodec";
const String kOptionAllowAutoRecordIncoming = "allow-auto-record-incoming";
const String kOptionAllowAutoRecordOutgoing = "allow-auto-record-outgoing";
const String kOptionVideoSaveDirectory = "video-save-directory";
const String kOptionAccessMode = "access-mode";
const String kOptionEnableKeyboard = "enable-keyboard";
// "Settings -> Security -> Permissions"
const String kOptionEnableRemotePrinter = "enable-remote-printer";
const String kOptionEnableClipboard = "enable-clipboard";
const String kOptionEnableFileTransfer = "enable-file-transfer";
const String kOptionEnableAudio = "enable-audio";
const String kOptionEnableCamera = "enable-camera";
const String kOptionEnableTerminal = "enable-terminal";
const String kOptionTerminalPersistent = "terminal-persistent";
const String kOptionEnableTunnel = "enable-tunnel";
const String kOptionEnableRemoteRestart = "enable-remote-restart";
const String kOptionEnableBlockInput = "enable-block-input";
const String kOptionEnablePrivacyMode = "enable-privacy-mode";
const String kOptionEnablePermChangeInAcceptWindow =
"enable-perm-change-in-accept-window";
const String kOptionAllowRemoteConfigModification =
"allow-remote-config-modification";
const String kOptionVerificationMethod = "verification-method";
const String kOptionApproveMode = "approve-mode";
const String kOptionAllowNumericOneTimePassword =
"allow-numeric-one-time-password";
const String kOptionCollapseToolbar = "collapse_toolbar";
const String kOptionHideToolbar = "hide-toolbar";
const String kOptionShowRemoteCursor = "show_remote_cursor";
const String kOptionFollowRemoteCursor = "follow_remote_cursor";
const String kOptionFollowRemoteWindow = "follow_remote_window";
const String kOptionZoomCursor = "zoom-cursor";
const String kOptionShowQualityMonitor = "show_quality_monitor";
const String kOptionDisableAudio = "disable_audio";
const String kOptionEnableFileCopyPaste = "enable-file-copy-paste";
// "Settings -> Display -> Other default options"
const String kOptionDisableClipboard = "disable_clipboard";
const String kOptionLockAfterSessionEnd = "lock_after_session_end";
const String kOptionPrivacyMode = "privacy_mode";
const String kOptionTouchMode = "touch-mode";
const String kOptionI444 = "i444";
const String kOptionSwapLeftRightMouse = "swap-left-right-mouse";
const String kOptionCodecPreference = "codec-preference";
const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left";
const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right";
const String kOptionRemoteMenubarEdge = "remote-menubar-edge";
const String kOptionRemoteMenubarFraction = "remote-menubar-frac";
const String kOptionAllowMultiEdgeToolbarDock =
"allow-multi-edge-toolbar-dock";
const String kOptionHideAbTagsPanel = "hideAbTagsPanel";
const String kOptionRemoteMenubarState = "remoteMenubarState";
const String kOptionPeerSorting = "peer-sorting";
const String kOptionPeerTabIndex = "peer-tab-index";
const String kOptionPeerTabOrder = "peer-tab-order";
const String kOptionPeerTabVisible = "peer-tab-visible";
const String kOptionPeerCardUiType = "peer-card-ui-type";
const String kOptionCurrentAbName = "current-ab-name";
const String kOptionEnableConfirmClosingTabs = "enable-confirm-closing-tabs";
const String kOptionAllowAlwaysSoftwareRender = "allow-always-software-render";
const String kOptionEnableCheckUpdate = "enable-check-update";
const String kOptionAllowAutoUpdate = "allow-auto-update";
const String kOptionAllowLinuxHeadless = "allow-linux-headless";
const String kOptionAllowRemoveWallpaper = "allow-remove-wallpaper";
const String kOptionStopService = "stop-service";
const String kOptionDirectxCapture = "enable-directx-capture";
const String kOptionAllowRemoteCmModification = "allow-remote-cm-modification";
const String kOptionEnableUdpPunch = "enable-udp-punch";
const String kOptionEnableIpv6Punch = "enable-ipv6-punch";
const String kOptionEnableTrustedDevices = "enable-trusted-devices";
const String kOptionShowVirtualMouse = "show-virtual-mouse";
const String kOptionVirtualMouseScale = "virtual-mouse-scale";
const String kOptionShowVirtualJoystick = "show-virtual-joystick";
const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note";
const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys";
// network options
const String kOptionAllowWebSocket = "allow-websocket";
const String kOptionAllowInsecureTLSFallback = "allow-insecure-tls-fallback";
const String kOptionDisableUdp = "disable-udp";
const String kOptionEnableFlutterHttpOnRust = "enable-flutter-http-on-rust";
// builtin options
const String kOptionHideServerSetting = "hide-server-settings";
const String kOptionHideProxySetting = "hide-proxy-settings";
const String kOptionHideWebSocketSetting = "hide-websocket-settings";
const String kOptionHideStopService = "hide-stop-service";
const String kOptionHideRemotePrinterSetting = "hide-remote-printer-settings";
const String kOptionHideSecuritySetting = "hide-security-settings";
const String kOptionHideNetworkSetting = "hide-network-settings";
const String kOptionRemovePresetPasswordWarning =
"remove-preset-password-warning";
const String kOptionDisableChangePermanentPassword =
"disable-change-permanent-password";
const String kOptionDisableChangeId = "disable-change-id";
const String kOptionDisableUnlockPin = "disable-unlock-pin";
const kHideUsernameOnCard = "hide-username-on-card";
const String kOptionHideHelpCards = "hide-help-cards";
const String kOptionAllowDeepLinkPassword = "allow-deep-link-password";
const String kOptionAllowDeepLinkServerSettings =
"allow-deep-link-server-settings";
const String kOptionToggleViewOnly = "view-only";
const String kOptionToggleShowMyCursor = "show-my-cursor";
const String kOptionDisableFloatingWindow = "disable-floating-window";
const String kOptionKeepScreenOn = "keep-screen-on";
const String kOptionKeepAwakeDuringIncomingSessions = "keep-awake-during-incoming-sessions";
const String kOptionKeepAwakeDuringOutgoingSessions = "keep-awake-during-outgoing-sessions";
const String kOptionShowMobileAction = "showMobileActions";
const String kUrlActionClose = "close";
const String kTabLabelHomePage = "Home";
const String kTabLabelSettingPage = "Settings";
const String kWindowPrefix = "wm_";
const int kWindowMainId = 0;
const String kPointerEventKindTouch = "touch";
const String kPointerEventKindMouse = "mouse";
const String kMouseEventTypeDefault = "";
const String kMouseEventTypePanStart = "pan_start";
const String kMouseEventTypePanUpdate = "pan_update";
const String kMouseEventTypePanEnd = "pan_end";
const String kMouseEventTypeDown = "down";
const String kMouseEventTypeUp = "up";
const String kKeyFlutterKey = "flutter_key";
const String kKeyShowDisplaysAsIndividualWindows =
'displays_as_individual_windows';
const String kKeyUseAllMyDisplaysForTheRemoteSession =
'use_all_my_displays_for_the_remote_session';
const String kKeyShowMonitorsToolbar = 'show_monitors_toolbar';
const String kKeyReverseMouseWheel = "reverse_mouse_wheel";
const String kMsgboxTextWaitingForImage = 'Connected, waiting for image...';
// the executable name of the portable version
const String kEnvPortableExecutable = "RUSTDESK_APPNAME";
const Color kColorWarn = Color.fromARGB(255, 245, 133, 59);
const Color kColorCanvas = Colors.black;
const int kMobileDefaultDisplayWidth = 720;
const int kMobileDefaultDisplayHeight = 1280;
const int kDesktopDefaultDisplayWidth = 1080;
const int kDesktopDefaultDisplayHeight = 720;
const int kMobileMaxDisplaySize = 1280;
const int kDesktopMaxDisplaySize = 3840;
const double kDesktopFileTransferRowHeight = 30.0;
const double kDesktopFileTransferHeaderHeight = 25.0;
const double kMinFps = 5;
const double kDefaultFps = 30;
const double kMaxFps = 120;
const double kMinQuality = 10;
const double kDefaultQuality = 50;
const double kMaxQuality = 100;
const double kMaxMoreQuality = 2000;
// trackpad speed
const String kKeyTrackpadSpeed = 'trackpad-speed';
const int kMinTrackpadSpeed = 10;
const int kDefaultTrackpadSpeed = 100;
const int kMaxTrackpadSpeed = 1000;
// relative mouse mode
/// Throttle duration (in milliseconds) for updating pointer lock center during
/// window move/resize events. Lower values provide more responsive updates but
/// may cause performance issues during rapid window operations.
const int kDefaultPointerLockCenterThrottleMs = 100;
/// Minimum server version required for relative mouse mode (MOUSE_TYPE_MOVE_RELATIVE).
/// Servers older than this version will ignore relative mouse events.
///
/// IMPORTANT: This value must be kept in sync with the Rust constant
/// `MIN_VERSION_RELATIVE_MOUSE_MODE` in `src/common.rs`.
const String kMinVersionForRelativeMouseMode = '1.4.5';
/// Maximum delta value for relative mouse movement.
/// Large values could cause issues with i32 overflow on server side,
/// and no reasonable mouse movement should exceed this bound.
///
/// IMPORTANT: This value must be kept in sync with the Rust constant
/// `MAX_RELATIVE_MOUSE_DELTA` in `src/server/input_service.rs`.
const int kMaxRelativeMouseDelta = 10000;
/// Debounce duration (in milliseconds) for relative mouse mode toggle.
/// This prevents double-toggle from race condition between Rust rdev grab loop
/// and Flutter keyboard handling. Value should be small enough to allow
/// intentional quick toggles but large enough to prevent accidental double-triggers.
const int kRelativeMouseModeToggleDebounceMs = 150;
// incomming (should be incoming) is kept, because change it will break the previous setting.
const String kKeyPrinterIncomingJobAction = 'printer-incomming-job-action';
const String kValuePrinterIncomingJobDismiss = 'dismiss';
const String kValuePrinterIncomingJobDefault = '';
const String kValuePrinterIncomingJobSelected = 'selected';
const String kKeyPrinterSelected = 'printer-selected-name';
const String kKeyPrinterSave = 'allow-printer-dialog-save';
const String kKeyPrinterAllowAutoPrint = 'allow-printer-auto-print';
double kNewWindowOffset = isWindows
? 56.0
: isLinux
? 50.0
: isMacOS
? 30.0
: 50.0;
EdgeInsets get kDragToResizeAreaPadding => !kUseCompatibleUiMode && isLinux
? stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.value
? EdgeInsets.zero
: EdgeInsets.all(5.0)
: EdgeInsets.zero;
// https://en.wikipedia.org/wiki/Non-breaking_space
const int $nbsp = 0x00A0;
extension StringExtension on String {
String get nonBreaking => replaceAll(' ', String.fromCharCode($nbsp));
}
const Size kConnectionManagerWindowSizeClosedChat = Size(300, 490);
const Size kConnectionManagerWindowSizeOpenChat = Size(700, 490);
// Tabbar transition duration, now we remove the duration
const Duration kTabTransitionDuration = Duration.zero;
const double kEmptyMarginTop = 50;
const double kDesktopIconButtonSplashRadius = 20;
/// [kMinCursorSize] indicates min cursor (w, h)
const int kMinCursorSize = 12;
const kFullScreenEdgeSize = 0.0;
const kMaximizeEdgeSize = 0.0;
// Do not use kWindowResizeEdgeSize directly. Use `windowResizeEdgeSize` in `common.dart` instead.
const kWindowResizeEdgeSize = 5.0;
final kWindowBorderWidth = isWindows ? 0.0 : 1.0;
const kDesktopMenuPadding = EdgeInsets.only(left: 12.0, right: 3.0);
const kFrameBorderRadius = 12.0;
const kFrameClipRRectBorderRadius = 12.0;
const kFrameBoxShadowBlurRadius = 32.0;
const kFrameBoxShadowOffsetFocused = 4.0;
const kFrameBoxShadowOffsetUnfocused = 2.0;
const kInvalidValueStr = 'InvalidValueStr';
// Config key shared by flutter and other ui.
const kCommConfKeyTheme = 'theme';
const kCommConfKeyLang = 'lang';
const kMobilePageConstraints = BoxConstraints(maxWidth: 600);
/// [kMouseControlDistance] indicates the distance that self-side move to get control of mouse.
const kMouseControlDistance = 12;
/// [kMouseControlTimeoutMSec] indicates the timeout (in milliseconds) that self-side can get control of mouse.
const kMouseControlTimeoutMSec = 1000;
/// [kRemoteViewStyleOriginal] Show remote image without scaling.
const kRemoteViewStyleOriginal = 'original';
/// [kRemoteViewStyleAdaptive] Show remote image scaling by ratio factor.
const kRemoteViewStyleAdaptive = 'adaptive';
/// [kRemoteViewStyleCustom] Show remote image at a user-defined scale percent.
const kRemoteViewStyleCustom = 'custom';
/// [kRemoteScrollStyleAuto] Scroll image auto by position.
const kRemoteScrollStyleAuto = 'scrollauto';
/// [kRemoteScrollStyleBar] Scroll image with scroll bar.
const kRemoteScrollStyleBar = 'scrollbar';
/// [kRemoteScrollStyleEdge] Scroll image auto at edges.
const kRemoteScrollStyleEdge = 'scrolledge';
/// [kScrollModeDefault] Mouse or touchpad, the default scroll mode.
const kScrollModeDefault = 'default';
/// [kScrollModeReverse] Mouse or touchpad, the reverse scroll mode.
const kScrollModeReverse = 'reverse';
/// [kRemoteImageQualityBest] Best image quality.
const kRemoteImageQualityBest = 'best';
/// [kRemoteImageQualityBalanced] Balanced image quality, mid performance.
const kRemoteImageQualityBalanced = 'balanced';
/// [kRemoteImageQualityLow] Low image quality, better performance.
const kRemoteImageQualityLow = 'low';
/// [kRemoteImageQualityCustom] Custom image quality.
const kRemoteImageQualityCustom = 'custom';
const kIgnoreDpi = true;
const Set<PointerDeviceKind> kTouchBasedDeviceKinds = {
PointerDeviceKind.touch,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
};
// Scale custom related constants
const String kCustomScalePercentKey =
'custom_scale_percent'; // Flutter option key for storing custom scale percent (integer 5-1000)
const int kScaleCustomMinPercent = 5;
const int kScaleCustomPivotPercent = 100; // 100% should be at 1/3 of track
const int kScaleCustomMaxPercent = 1000;
const double kScaleCustomPivotPos = 1.0 / 3.0; // first 1/3 → up to 100%
const double kScaleCustomDetentEpsilon =
0.006; // snap range around pivot (~0.6%)
const Duration kDebounceCustomScaleDuration = Duration(milliseconds: 300);
// ================================ mobile ================================
// Magic numbers, maybe need to avoid it or use a better way to get them.
const kMobileDelaySoftKeyboard = Duration(milliseconds: 30);
const kMobileDelaySoftKeyboardFocus = Duration(milliseconds: 30);
/// Android constants
const kActionApplicationDetailsSettings =
"android.settings.APPLICATION_DETAILS_SETTINGS";
const kActionAccessibilitySettings = "android.settings.ACCESSIBILITY_SETTINGS";
const kRecordAudio = "android.permission.RECORD_AUDIO";
const kManageExternalStorage = "android.permission.MANAGE_EXTERNAL_STORAGE";
const kRequestIgnoreBatteryOptimizations =
"android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS";
const kSystemAlertWindow = "android.permission.SYSTEM_ALERT_WINDOW";
const kAndroid13Notification = "android.permission.POST_NOTIFICATIONS";
/// Android channel invoke type key
class AndroidChannel {
static final kStartAction = "start_action";
static final kGetStartOnBootOpt = "get_start_on_boot_opt";
static final kSetStartOnBootOpt = "set_start_on_boot_opt";
static final kSyncAppDirConfigPath = "sync_app_dir";
}
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _keyLabels
/// see [LogicalKeyboardKey.keyLabel]
const Map<int, String> logicalKeyMap = <int, String>{
0x00000000020: 'VK_SPACE',
0x00000000022: 'VK_QUOTE',
0x0000000002c: 'VK_COMMA',
0x0000000002d: 'VK_MINUS',
0x0000000002f: 'VK_SLASH',
0x00000000030: 'VK_0',
0x00000000031: 'VK_1',
0x00000000032: 'VK_2',
0x00000000033: 'VK_3',
0x00000000034: 'VK_4',
0x00000000035: 'VK_5',
0x00000000036: 'VK_6',
0x00000000037: 'VK_7',
0x00000000038: 'VK_8',
0x00000000039: 'VK_9',
0x0000000003b: 'VK_SEMICOLON',
0x0000000003d: 'VK_PLUS', // it is =
0x0000000005b: 'VK_LBRACKET',
0x0000000005c: 'VK_BACKSLASH',
0x0000000005d: 'VK_RBRACKET',
0x00000000061: 'VK_A',
0x00000000062: 'VK_B',
0x00000000063: 'VK_C',
0x00000000064: 'VK_D',
0x00000000065: 'VK_E',
0x00000000066: 'VK_F',
0x00000000067: 'VK_G',
0x00000000068: 'VK_H',
0x00000000069: 'VK_I',
0x0000000006a: 'VK_J',
0x0000000006b: 'VK_K',
0x0000000006c: 'VK_L',
0x0000000006d: 'VK_M',
0x0000000006e: 'VK_N',
0x0000000006f: 'VK_O',
0x00000000070: 'VK_P',
0x00000000071: 'VK_Q',
0x00000000072: 'VK_R',
0x00000000073: 'VK_S',
0x00000000074: 'VK_T',
0x00000000075: 'VK_U',
0x00000000076: 'VK_V',
0x00000000077: 'VK_W',
0x00000000078: 'VK_X',
0x00000000079: 'VK_Y',
0x0000000007a: 'VK_Z',
0x00100000008: 'VK_BACK',
0x00100000009: 'VK_TAB',
0x0010000000d: 'VK_ENTER',
0x0010000001b: 'VK_ESCAPE',
0x0010000007f: 'VK_DELETE',
0x00100000104: 'VK_CAPITAL',
0x00100000301: 'VK_DOWN',
0x00100000302: 'VK_LEFT',
0x00100000303: 'VK_RIGHT',
0x00100000304: 'VK_UP',
0x00100000305: 'VK_END',
0x00100000306: 'VK_HOME',
0x00100000307: 'VK_NEXT',
0x00100000308: 'VK_PRIOR',
0x00100000401: 'VK_CLEAR',
0x00100000407: 'VK_INSERT',
0x00100000504: 'VK_CANCEL',
0x00100000506: 'VK_EXECUTE',
0x00100000508: 'VK_HELP',
0x00100000509: 'VK_PAUSE',
0x0010000050c: 'VK_SELECT',
0x00100000608: 'VK_PRINT',
0x00100000705: 'VK_CONVERT',
0x00100000706: 'VK_FINAL',
0x00100000711: 'VK_HANGUL',
0x00100000712: 'VK_HANJA',
0x00100000713: 'VK_JUNJA',
0x00100000718: 'VK_KANA',
0x00100000719: 'VK_KANJI',
0x00100000801: 'VK_F1',
0x00100000802: 'VK_F2',
0x00100000803: 'VK_F3',
0x00100000804: 'VK_F4',
0x00100000805: 'VK_F5',
0x00100000806: 'VK_F6',
0x00100000807: 'VK_F7',
0x00100000808: 'VK_F8',
0x00100000809: 'VK_F9',
0x0010000080a: 'VK_F10',
0x0010000080b: 'VK_F11',
0x0010000080c: 'VK_F12',
0x00100000d2b: 'Apps',
0x00200000002: 'VK_SLEEP',
0x00200000100: 'VK_CONTROL',
0x00200000101: 'RControl',
0x00200000102: 'VK_SHIFT',
0x00200000103: 'RShift',
0x00200000104: 'VK_MENU',
0x00200000105: 'RAlt',
0x002000001f0: 'VK_CONTROL',
0x002000001f2: 'VK_SHIFT',
0x002000001f4: 'VK_MENU',
0x002000001f6: 'Meta',
0x0020000022a: 'VK_MULTIPLY',
0x0020000022b: 'VK_ADD',
0x0020000022d: 'VK_SUBTRACT',
0x0020000022e: 'VK_DECIMAL',
0x0020000022f: 'VK_DIVIDE',
0x00200000230: 'VK_NUMPAD0',
0x00200000231: 'VK_NUMPAD1',
0x00200000232: 'VK_NUMPAD2',
0x00200000233: 'VK_NUMPAD3',
0x00200000234: 'VK_NUMPAD4',
0x00200000235: 'VK_NUMPAD5',
0x00200000236: 'VK_NUMPAD6',
0x00200000237: 'VK_NUMPAD7',
0x00200000238: 'VK_NUMPAD8',
0x00200000239: 'VK_NUMPAD9',
};
/// flutter/packages/flutter/lib/src/services/keyboard_key.dart -> _debugName
/// see [PhysicalKeyboardKey.debugName] -> _debugName
const Map<int, String> physicalKeyMap = <int, String>{
0x00010082: 'VK_SLEEP',
0x00070004: 'VK_A',
0x00070005: 'VK_B',
0x00070006: 'VK_C',
0x00070007: 'VK_D',
0x00070008: 'VK_E',
0x00070009: 'VK_F',
0x0007000a: 'VK_G',
0x0007000b: 'VK_H',
0x0007000c: 'VK_I',
0x0007000d: 'VK_J',
0x0007000e: 'VK_K',
0x0007000f: 'VK_L',
0x00070010: 'VK_M',
0x00070011: 'VK_N',
0x00070012: 'VK_O',
0x00070013: 'VK_P',
0x00070014: 'VK_Q',
0x00070015: 'VK_R',
0x00070016: 'VK_S',
0x00070017: 'VK_T',
0x00070018: 'VK_U',
0x00070019: 'VK_V',
0x0007001a: 'VK_W',
0x0007001b: 'VK_X',
0x0007001c: 'VK_Y',
0x0007001d: 'VK_Z',
0x0007001e: 'VK_1',
0x0007001f: 'VK_2',
0x00070020: 'VK_3',
0x00070021: 'VK_4',
0x00070022: 'VK_5',
0x00070023: 'VK_6',
0x00070024: 'VK_7',
0x00070025: 'VK_8',
0x00070026: 'VK_9',
0x00070027: 'VK_0',
0x00070028: 'VK_ENTER',
0x00070029: 'VK_ESCAPE',
0x0007002a: 'VK_BACK',
0x0007002b: 'VK_TAB',
0x0007002c: 'VK_SPACE',
0x0007002d: 'VK_MINUS',
0x0007002e: 'VK_PLUS', // it is =
0x0007002f: 'VK_LBRACKET',
0x00070030: 'VK_RBRACKET',
0x00070033: 'VK_SEMICOLON',
0x00070034: 'VK_QUOTE',
0x00070036: 'VK_COMMA',
0x00070038: 'VK_SLASH',
0x00070039: 'VK_CAPITAL',
0x0007003a: 'VK_F1',
0x0007003b: 'VK_F2',
0x0007003c: 'VK_F3',
0x0007003d: 'VK_F4',
0x0007003e: 'VK_F5',
0x0007003f: 'VK_F6',
0x00070040: 'VK_F7',
0x00070041: 'VK_F8',
0x00070042: 'VK_F9',
0x00070043: 'VK_F10',
0x00070044: 'VK_F11',
0x00070045: 'VK_F12',
0x00070049: 'VK_INSERT',
0x0007004a: 'VK_HOME',
0x0007004b: 'VK_PRIOR', // Page Up
0x0007004c: 'VK_DELETE',
0x0007004d: 'VK_END',
0x0007004e: 'VK_NEXT', // Page Down
0x0007004f: 'VK_RIGHT',
0x00070050: 'VK_LEFT',
0x00070051: 'VK_DOWN',
0x00070052: 'VK_UP',
0x00070053: 'Num Lock', // TODO rust not impl
0x00070054: 'VK_DIVIDE', // numpad
0x00070055: 'VK_MULTIPLY',
0x00070056: 'VK_SUBTRACT',
0x00070057: 'VK_ADD',
0x00070058: 'VK_ENTER', // num enter
0x00070059: 'VK_NUMPAD1',
0x0007005a: 'VK_NUMPAD2',
0x0007005b: 'VK_NUMPAD3',
0x0007005c: 'VK_NUMPAD4',
0x0007005d: 'VK_NUMPAD5',
0x0007005e: 'VK_NUMPAD6',
0x0007005f: 'VK_NUMPAD7',
0x00070060: 'VK_NUMPAD8',
0x00070061: 'VK_NUMPAD9',
0x00070062: 'VK_NUMPAD0',
0x00070063: 'VK_DECIMAL',
0x00070075: 'VK_HELP',
0x00070077: 'VK_SELECT',
0x00070088: 'VK_KANA',
0x0007008a: 'VK_CONVERT',
0x000700e0: 'VK_CONTROL',
0x000700e1: 'VK_SHIFT',
0x000700e2: 'VK_MENU',
0x000700e3: 'Meta',
0x000700e4: 'RControl',
0x000700e5: 'RShift',
0x000700e6: 'RAlt',
0x000700e7: 'RWin',
0x000c00b1: 'VK_PAUSE',
0x000c00cd: 'VK_PAUSE',
0x000c019e: 'LOCK_SCREEN',
0x000c0208: 'VK_PRINT',
};
/// The windows targets in the publish time order.
enum WindowsTarget {
naw, // not a windows target
xp,
vista,
w7,
w8,
w8_1,
w10,
w11
}
/// A convenient method to transform a build number to the corresponding windows version.
extension WindowsTargetExt on int {
WindowsTarget get windowsVersion => getWindowsTarget(this);
}
const kCheckSoftwareUpdateFinish = 'check_software_update_finish';
@@ -0,0 +1,615 @@
// main window right pane
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:window_manager/window_manager.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import '../../common.dart';
import '../../common/formatter/id_formatter.dart';
import '../../common/widgets/peer_tab_page.dart';
import '../../common/widgets/autocomplete.dart';
import '../../models/platform_model.dart';
import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
class OnlineStatusWidget extends StatefulWidget {
const OnlineStatusWidget({Key? key, this.onSvcStatusChanged})
: super(key: key);
final VoidCallback? onSvcStatusChanged;
@override
State<OnlineStatusWidget> createState() => _OnlineStatusWidgetState();
}
/// State for the connection page.
class _OnlineStatusWidgetState extends State<OnlineStatusWidget> {
final _svcStopped = Get.find<RxBool>(tag: 'stop-service');
final _svcIsUsingPublicServer = true.obs;
Timer? _updateTimer;
double get em => 14.0;
double? get height => bind.isIncomingOnly() ? null : em * 3;
void onUsePublicServerGuide() {
const url = "https://rustdesk.com/pricing";
canLaunchUrlString(url).then((can) {
if (can) {
launchUrlString(url);
}
});
}
@override
void initState() {
super.initState();
_updateTimer = periodic_immediate(Duration(seconds: 1), () async {
updateStatus();
});
}
@override
void dispose() {
_updateTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isIncomingOnly = bind.isIncomingOnly();
startServiceWidget() => Offstage(
offstage: !_svcStopped.value,
child: InkWell(
onTap: () async {
await start_service(true);
},
child: Text(translate("Start service"),
style: TextStyle(
decoration: TextDecoration.underline, fontSize: em)))
.marginOnly(left: em),
);
setupServerWidget() => Flexible(
child: Offstage(
offstage: !(!_svcStopped.value &&
stateGlobal.svcStatus.value == SvcStatus.ready &&
_svcIsUsingPublicServer.value),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(', ', style: TextStyle(fontSize: em)),
Flexible(
child: InkWell(
onTap: onUsePublicServerGuide,
child: Row(
children: [
Flexible(
child: Text(
translate('setup_server_tip'),
style: TextStyle(
decoration: TextDecoration.underline,
fontSize: em),
),
),
],
),
),
)
],
),
),
);
basicWidget() => Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
height: 8,
width: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: _svcStopped.value ||
stateGlobal.svcStatus.value == SvcStatus.connecting
? kColorWarn
: (stateGlobal.svcStatus.value == SvcStatus.ready
? Color.fromARGB(255, 50, 190, 166)
: Color.fromARGB(255, 224, 79, 95)),
),
).marginSymmetric(horizontal: em),
Container(
width: isIncomingOnly ? 226 : null,
child: _buildConnStatusMsg(),
),
// stop
if (!isIncomingOnly) startServiceWidget(),
// ready && public
// No need to show the guide if is custom client.
if (!isIncomingOnly) setupServerWidget(),
],
);
return Container(
height: height,
child: Obx(() => isIncomingOnly
? Column(
children: [
basicWidget(),
Align(
child: startServiceWidget(),
alignment: Alignment.centerLeft)
.marginOnly(top: 2.0, left: 22.0),
],
)
: basicWidget()),
).paddingOnly(right: isIncomingOnly ? 8 : 0);
}
_buildConnStatusMsg() {
widget.onSvcStatusChanged?.call();
return Text(
_svcStopped.value
? translate("Service is not running")
: stateGlobal.svcStatus.value == SvcStatus.connecting
? translate("connecting_status")
: stateGlobal.svcStatus.value == SvcStatus.notReady
? translate("not_ready_status")
: translate('Ready'),
style: TextStyle(fontSize: em),
);
}
updateStatus() async {
final status =
jsonDecode(await bind.mainGetConnectStatus()) as Map<String, dynamic>;
final statusNum = status['status_num'] as int;
if (statusNum == 0) {
stateGlobal.svcStatus.value = SvcStatus.connecting;
} else if (statusNum == -1) {
stateGlobal.svcStatus.value = SvcStatus.notReady;
} else if (statusNum == 1) {
stateGlobal.svcStatus.value = SvcStatus.ready;
} else {
stateGlobal.svcStatus.value = SvcStatus.notReady;
}
_svcIsUsingPublicServer.value = await bind.mainIsUsingPublicServer();
try {
stateGlobal.videoConnCount.value = status['video_conn_count'] as int;
} catch (_) {}
}
}
/// Connection page for connecting to a remote peer.
class ConnectionPage extends StatefulWidget {
const ConnectionPage({Key? key}) : super(key: key);
@override
State<ConnectionPage> createState() => _ConnectionPageState();
}
/// State for the connection page.
class _ConnectionPageState extends State<ConnectionPage>
with SingleTickerProviderStateMixin, WindowListener {
/// Controller for the id input bar.
final _idController = IDTextEditingController();
final RxBool _idInputFocused = false.obs;
final FocusNode _idFocusNode = FocusNode();
final TextEditingController _idEditingController = TextEditingController();
String selectedConnectionType = 'Connect';
bool isWindowMinimized = false;
final AllPeersLoader _allPeersLoader = AllPeersLoader();
// https://github.com/flutter/flutter/issues/157244
Iterable<Peer> _autocompleteOpts = [];
final _menuOpen = false.obs;
@override
void initState() {
super.initState();
_allPeersLoader.init(setState);
_idFocusNode.addListener(onFocusChanged);
if (_idController.text.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) {
setState(() {
_idController.id = lastRemoteId;
});
}
});
}
Get.put<TextEditingController>(_idEditingController);
Get.put<IDTextEditingController>(_idController);
windowManager.addListener(this);
}
@override
void dispose() {
_idController.dispose();
windowManager.removeListener(this);
_allPeersLoader.clear();
_idFocusNode.removeListener(onFocusChanged);
_idFocusNode.dispose();
_idEditingController.dispose();
if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>();
}
if (Get.isRegistered<TextEditingController>()) {
Get.delete<TextEditingController>();
}
super.dispose();
}
@override
void onWindowEvent(String eventName) {
super.onWindowEvent(eventName);
if (eventName == 'minimize') {
isWindowMinimized = true;
} else if (eventName == 'maximize' || eventName == 'restore') {
if (isWindowMinimized && isWindows) {
// windows can't update when minimized.
Get.forceAppUpdate();
}
isWindowMinimized = false;
}
}
@override
void onWindowEnterFullScreen() {
// Remove edge border by setting the value to zero.
stateGlobal.resizeEdgeSize.value = 0;
}
@override
void onWindowLeaveFullScreen() {
// Restore edge border to default edge size.
stateGlobal.resizeEdgeSize.value = stateGlobal.isMaximized.isTrue
? kMaximizeEdgeSize
: windowResizeEdgeSize;
}
@override
void onWindowClose() {
super.onWindowClose();
bind.mainOnMainWindowClose();
}
void onFocusChanged() {
_idInputFocused.value = _idFocusNode.hasFocus;
if (_idFocusNode.hasFocus) {
if (_allPeersLoader.needLoad) {
_allPeersLoader.getAllPeers();
}
final textLength = _idEditingController.value.text.length;
// Select all to facilitate removing text, just following the behavior of address input of chrome.
_idEditingController.selection =
TextSelection(baseOffset: 0, extentOffset: textLength);
}
}
@override
Widget build(BuildContext context) {
final isOutgoingOnly = bind.isOutgoingOnly();
return Column(
children: [
Expanded(
child: Column(
children: [
Row(
children: [
Flexible(child: _buildRemoteIDTextField(context)),
],
).marginOnly(top: 22),
SizedBox(height: 12),
Divider().paddingOnly(right: 12),
Expanded(child: PeerTabPage()),
],
).paddingOnly(left: 12.0)),
if (!isOutgoingOnly) const Divider(height: 1),
if (!isOutgoingOnly) OnlineStatusWidget()
],
);
}
/// Callback for the connect button.
/// Connects to the selected peer.
void onConnect(
{bool isFileTransfer = false,
bool isViewCamera = false,
bool isTerminal = false}) {
var id = _idController.id;
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal);
}
/// UI for the remote ID TextField.
/// Search for a peer.
Widget _buildRemoteIDTextField(BuildContext context) {
var w = Container(
width: 320 + 20 * 2,
padding: const EdgeInsets.fromLTRB(20, 24, 20, 22),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(13)),
border: Border.all(color: Theme.of(context).colorScheme.background)),
child: Ink(
child: Column(
children: [
getConnectionPageTitle(context, false).marginOnly(bottom: 15),
Row(
children: [
Expanded(
child: RawAutocomplete<Peer>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == '') {
_autocompleteOpts = const Iterable<Peer>.empty();
} else if (_allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded) {
Peer emptyPeer = Peer(
id: '',
username: '',
hostname: '',
alias: '',
platform: '',
tags: [],
hash: '',
password: '',
forceAlwaysRelay: false,
rdpPort: '',
rdpUsername: '',
loginName: '',
device_group_name: '',
note: '',
);
_autocompleteOpts = [emptyPeer];
} else {
String textWithoutSpaces =
textEditingValue.text.replaceAll(" ", "");
if (int.tryParse(textWithoutSpaces) != null) {
textEditingValue = TextEditingValue(
text: textWithoutSpaces,
selection: textEditingValue.selection,
);
}
String textToFind = textEditingValue.text.toLowerCase();
_autocompleteOpts = _allPeersLoader.peers
.where((peer) =>
peer.id.toLowerCase().contains(textToFind) ||
peer.username
.toLowerCase()
.contains(textToFind) ||
peer.hostname
.toLowerCase()
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
}
return _autocompleteOpts;
},
focusNode: _idFocusNode,
textEditingController: _idEditingController,
fieldViewBuilder: (
BuildContext context,
TextEditingController fieldTextEditingController,
FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted,
) {
updateTextAndPreserveSelection(
fieldTextEditingController, _idController.text);
return Obx(() => TextField(
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
focusNode: fieldFocusNode,
style: const TextStyle(
fontFamily: 'WorkSans',
fontSize: 22,
height: 1.4,
),
maxLines: 1,
cursorColor:
Theme.of(context).textTheme.titleLarge?.color,
decoration: InputDecoration(
filled: false,
counterText: '',
hintText: _idInputFocused.value
? null
: translate('Enter Remote ID'),
contentPadding: const EdgeInsets.symmetric(
horizontal: 15, vertical: 13)),
controller: fieldTextEditingController,
inputFormatters: [IDTextInputFormatter()],
onChanged: (v) {
_idController.id = v;
},
onSubmitted: (_) {
onConnect();
},
).workaroundFreezeLinuxMint());
},
onSelected: (option) {
setState(() {
_idController.id = option.id;
FocusScope.of(context).unfocus();
});
},
optionsViewBuilder: (BuildContext context,
AutocompleteOnSelected<Peer> onSelected,
Iterable<Peer> options) {
options = _autocompleteOpts;
double maxHeight = options.length * 50;
if (options.length == 1) {
maxHeight = 52;
} else if (options.length == 3) {
maxHeight = 146;
} else if (options.length == 4) {
maxHeight = 193;
}
maxHeight = maxHeight.clamp(0, 200);
return Align(
alignment: Alignment.topLeft,
child: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 5,
spreadRadius: 1,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxHeight,
maxWidth: 319,
),
child: _allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded
? Container(
height: 80,
child: Center(
child: CircularProgressIndicator(
strokeWidth: 2,
),
))
: Padding(
padding:
const EdgeInsets.only(top: 5),
child: ListView(
children: options
.map((peer) =>
AutocompletePeerTile(
onSelect: () =>
onSelected(peer),
peer: peer))
.toList(),
),
),
),
))),
);
},
)),
],
),
Padding(
padding: const EdgeInsets.only(top: 13.0),
child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
SizedBox(
height: 28.0,
child: ElevatedButton(
onPressed: () {
onConnect();
},
child: Text(translate("Connect")),
),
),
const SizedBox(width: 8),
Container(
height: 28.0,
width: 28.0,
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: StatefulBuilder(
builder: (context, setState) {
var offset = Offset(0, 0);
return Obx(() => InkWell(
child: _menuOpen.value
? Transform.rotate(
angle: pi,
child: Icon(IconFont.more, size: 14),
)
: Icon(IconFont.more, size: 14),
onTapDown: (e) {
offset = e.globalPosition;
},
onTap: () async {
_menuOpen.value = true;
final x = offset.dx;
final y = offset.dy;
await mod_menu
.showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: [
(
'Transfer file',
() => onConnect(isFileTransfer: true)
),
(
'View camera',
() => onConnect(isViewCamera: true)
),
(
'${translate('Terminal')} (beta)',
() => onConnect(isTerminal: true)
),
]
.map((e) => MenuEntryButton<String>(
childBuilder: (TextStyle? style) =>
Text(
translate(e.$1),
style: style,
),
proc: () => e.$2(),
padding: EdgeInsets.symmetric(
horizontal:
kDesktopMenuPadding.left),
dismissOnClicked: true,
))
.map((e) => e.build(
context,
const MenuConfig(
commonColor: CustomPopupMenuTheme
.commonColor,
height:
CustomPopupMenuTheme.height,
dividerHeight:
CustomPopupMenuTheme
.dividerHeight)))
.expand((i) => i)
.toList(),
elevation: 8,
)
.then((_) {
_menuOpen.value = false;
});
},
));
},
),
),
),
]),
),
],
),
),
);
return Container(
constraints: const BoxConstraints(maxWidth: 600), child: w);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/desktop/pages/desktop_setting_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:window_manager/window_manager.dart';
// import 'package:flutter/services.dart';
import '../../common/shared_state.dart';
class DesktopTabPage extends StatefulWidget {
const DesktopTabPage({Key? key}) : super(key: key);
@override
State<DesktopTabPage> createState() => _DesktopTabPageState();
static void onAddSetting(
{SettingsTabKey initialPage = SettingsTabKey.general}) {
try {
DesktopTabController tabController = Get.find<DesktopTabController>();
tabController.add(TabInfo(
key: kTabLabelSettingPage,
label: kTabLabelSettingPage,
selectedIcon: Icons.build_sharp,
unselectedIcon: Icons.build_outlined,
page: DesktopSettingPage(
key: const ValueKey(kTabLabelSettingPage),
initialTabkey: initialPage,
)));
} catch (e) {
debugPrintStack(label: '$e');
}
}
}
class _DesktopTabPageState extends State<DesktopTabPage> {
final tabController = DesktopTabController(tabType: DesktopTabType.main);
_DesktopTabPageState() {
RemoteCountState.init();
Get.put<DesktopTabController>(tabController);
tabController.add(TabInfo(
key: kTabLabelHomePage,
label: kTabLabelHomePage,
selectedIcon: Icons.home_sharp,
unselectedIcon: Icons.home_outlined,
closable: false,
page: DesktopHomePage(
key: const ValueKey(kTabLabelHomePage),
)));
if (bind.isIncomingOnly()) {
tabController.onSelected = (key) {
if (key == kTabLabelHomePage) {
windowManager.setSize(getIncomingOnlyHomeSize());
setResizable(false);
} else {
windowManager.setSize(getIncomingOnlySettingsSize());
setResizable(true);
}
};
}
}
@override
void initState() {
super.initState();
// HardwareKeyboard.instance.addHandler(_handleKeyEvent);
}
/*
bool _handleKeyEvent(KeyEvent event) {
if (!mouseIn && event is KeyDownEvent) {
print('key down: ${event.logicalKey}');
shouldBeBlocked(_block, canBeBlocked);
}
return false; // allow it to propagate
}
*/
@override
void dispose() {
// HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
Get.delete<DesktopTabController>();
super.dispose();
}
@override
Widget build(BuildContext context) {
final tabWidget = Container(
child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: DesktopTab(
controller: tabController,
tail: Offstage(
offstage: bind.isIncomingOnly() || bind.isDisableSettings(),
child: ActionIcon(
message: 'Settings',
icon: IconFont.menu,
onTap: DesktopTabPage.onAddSetting,
isClose: false,
),
),
)));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: Obx(
() => DragToResizeArea(
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: windowManagerEnableResizeEdges,
child: tabWidget,
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,177 @@
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/pages/file_manager_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
/// File Transfer for multi tabs
class FileManagerTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const FileManagerTabPage({Key? key, required this.params}) : super(key: key);
@override
State<FileManagerTabPage> createState() => _FileManagerTabPageState(params);
}
class _FileManagerTabPageState extends State<FileManagerTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
static const IconData selectedIcon = Icons.file_copy_sharp;
static const IconData unselectedIcon = Icons.file_copy_outlined;
_FileManagerTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.fileTransfer));
tabController.onSelected = (id) {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.add(TabInfo(
key: params['id'],
label: params['id'],
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: params['id'],
tabController: tabController,
)) {
return;
}
tabController.closeBy(params['id']);
},
page: FileManagerPage(
key: ValueKey(params['id']),
id: params['id'],
password: params['password'],
isSharedPassword: params['isSharedPassword'],
tabController: tabController,
forceRelay: params['forceRelay'],
connToken: params['connToken'],
)));
}
@override
void initState() {
super.initState();
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
debugPrint(
"[FileTransfer] call ${call.method} with args ${call.arguments} from window $fromWindowId to ${windowId()}");
// for simplify, just replace connectionId
if (call.method == kWindowEventNewFileTransfer) {
final args = jsonDecode(call.arguments);
final id = args['id'];
windowOnTop(windowId());
tabController.add(TabInfo(
key: id,
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(id);
},
page: FileManagerPage(
key: ValueKey(id),
id: id,
password: args['password'],
isSharedPassword: args['isSharedPassword'],
tabController: tabController,
forceRelay: args['forceRelay'],
connToken: args['connToken'],
)));
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
}
});
Future.delayed(Duration.zero, () {
restoreWindowPosition(WindowType.FileTransfer, windowId: windowId());
});
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).cardColor,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
));
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Container(
decoration: BoxDecoration(
border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: SubWindowDragToResizeArea(
child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
);
}
void onRemoveId(String id) {
if (tabController.state.value.tabs.isEmpty) {
WindowController.fromWindowId(windowId()).close();
}
}
int windowId() {
return widget.params["windowId"];
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.state.value.tabs.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;
} else {
final bool res;
if (!option2bool(kOptionEnableConfirmClosingTabs,
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
res = true;
} else {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
}
return res;
}
}
}
+274
View File
@@ -0,0 +1,274 @@
import 'dart:convert';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:path/path.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:window_manager/window_manager.dart';
class InstallPage extends StatefulWidget {
const InstallPage({Key? key}) : super(key: key);
@override
State<InstallPage> createState() => _InstallPageState();
}
class _InstallPageState extends State<InstallPage> {
final tabController = DesktopTabController(tabType: DesktopTabType.main);
_InstallPageState() {
Get.put<DesktopTabController>(tabController);
const label = "install";
tabController.add(TabInfo(
key: label,
label: label,
closable: false,
page: _InstallPageBody(
key: const ValueKey(label),
)));
}
@override
void dispose() {
super.dispose();
Get.delete<DesktopTabController>();
}
@override
Widget build(BuildContext context) {
return DragToResizeArea(
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: windowManagerEnableResizeEdges,
child: Container(
child: Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: DesktopTab(controller: tabController)),
),
);
}
}
class _InstallPageBody extends StatefulWidget {
const _InstallPageBody({Key? key}) : super(key: key);
@override
State<_InstallPageBody> createState() => _InstallPageBodyState();
}
class _InstallPageBodyState extends State<_InstallPageBody>
with WindowListener {
late final TextEditingController controller;
final RxBool startmenu = true.obs;
final RxBool desktopicon = true.obs;
final RxBool printer = false.obs;
final RxBool showProgress = false.obs;
final RxBool btnEnabled = true.obs;
// todo move to theme.
final buttonStyle = OutlinedButton.styleFrom(
textStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
padding: EdgeInsets.symmetric(vertical: 15, horizontal: 12),
);
_InstallPageBodyState() {
controller = TextEditingController(text: bind.installInstallPath());
final installOptions = jsonDecode(bind.installInstallOptions());
startmenu.value = installOptions['STARTMENUSHORTCUTS'] != '0';
desktopicon.value = installOptions['DESKTOPSHORTCUTS'] != '0';
printer.value = installOptions['PRINTER'] == '1';
}
@override
void initState() {
windowManager.addListener(this);
super.initState();
}
@override
void dispose() {
windowManager.removeListener(this);
super.dispose();
}
@override
void onWindowClose() {
gFFI.close();
super.onWindowClose();
windowManager.setPreventClose(false);
windowManager.close();
}
InkWell Option(RxBool option, {String label = ''}) {
return InkWell(
// todo mouseCursor: "SystemMouseCursors.forbidden" or no cursor on btnEnabled == false
borderRadius: BorderRadius.circular(6),
onTap: () => btnEnabled.value ? option.value = !option.value : null,
child: Row(
children: [
Obx(
() => Checkbox(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
value: option.value,
onChanged: (v) =>
btnEnabled.value ? option.value = !option.value : null,
).marginOnly(right: 8),
),
Expanded(
child: Text(translate(label)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final double em = 13;
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
return Scaffold(
backgroundColor: null,
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate('Installation'),
style: Theme.of(context).textTheme.headlineMedium),
Row(
children: [
Text('${translate('Installation Path')}:')
.marginOnly(right: 10),
Expanded(
child: TextField(
controller: controller,
readOnly: true,
decoration: InputDecoration(
contentPadding: EdgeInsets.all(0.75 * em),
),
).workaroundFreezeLinuxMint().marginOnly(right: 10),
),
Obx(
() => OutlinedButton.icon(
icon: Icon(Icons.folder_outlined, size: 16),
onPressed: btnEnabled.value ? selectInstallPath : null,
style: buttonStyle,
label: Text(translate('Change Path')),
),
)
],
).marginSymmetric(vertical: 2 * em),
Option(startmenu, label: 'Create start menu shortcuts')
.marginOnly(bottom: 7),
Option(desktopicon, label: 'Create desktop icon')
.marginOnly(bottom: 7),
Option(printer, label: 'Install {$appName} Printer'),
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDarkTheme
? Color.fromARGB(135, 87, 87, 90)
: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey),
),
child: Row(
children: [
Icon(Icons.info_outline_rounded, size: 32)
.marginOnly(right: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate('agreement_tip'))
.marginOnly(bottom: em),
InkWell(
hoverColor: Colors.transparent,
onTap: () => launchUrlString(
'https://rustdesk.com/privacy.html'),
child: Tooltip(
message: 'https://rustdesk.com/privacy.html',
child: Row(children: [
Icon(Icons.launch_outlined, size: 16)
.marginOnly(right: 5),
Text(
translate('End-user license agreement'),
style: const TextStyle(
decoration: TextDecoration.underline),
)
]),
),
),
],
)
],
)).marginSymmetric(vertical: 2 * em),
Row(
children: [
Expanded(
// NOT use Offstage to wrap LinearProgressIndicator
child: Obx(() => showProgress.value
? LinearProgressIndicator().marginOnly(right: 10)
: Offstage()),
),
Obx(
() => OutlinedButton.icon(
icon: Icon(Icons.close_rounded, size: 16),
label: Text(translate('Cancel')),
onPressed:
btnEnabled.value ? () => windowManager.close() : null,
style: buttonStyle,
).marginOnly(right: 10),
),
Obx(
() => ElevatedButton.icon(
icon: Icon(Icons.done_rounded, size: 16),
label: Text(translate('Accept and Install')),
onPressed: btnEnabled.value ? install : null,
style: buttonStyle,
),
),
Offstage(
offstage: bind.installShowRunWithoutInstall(),
child: Obx(
() => OutlinedButton.icon(
icon: Icon(Icons.screen_share_outlined, size: 16),
label: Text(translate('Run without install')),
onPressed: btnEnabled.value
? () => bind.installRunWithoutInstall()
: null,
style: buttonStyle,
).marginOnly(left: 10),
),
),
],
)
],
).paddingSymmetric(horizontal: 4 * em, vertical: 3 * em),
));
}
void install() {
do_install() {
btnEnabled.value = false;
showProgress.value = true;
String args = '';
if (startmenu.value) args += ' startmenu';
if (desktopicon.value) args += ' desktopicon';
if (printer.value) args += ' printer';
bind.installInstallMe(options: args, path: controller.text);
}
do_install();
}
void selectInstallPath() async {
String? install_path = await FilePicker.platform
.getDirectoryPath(initialDirectory: controller.text);
if (install_path != null) {
controller.text = join(install_path, await bind.mainGetAppName());
}
}
}
@@ -0,0 +1,357 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
const double _kColumn1Width = 30;
const double _kColumn4Width = 100;
const double _kRowHeight = 60;
const double _kTextLeftMargin = 20;
class _PortForward {
int localPort;
String remoteHost;
int remotePort;
_PortForward.fromJson(List<dynamic> json)
: localPort = json[0] as int,
remoteHost = json[1] as String,
remotePort = json[2] as int;
}
class PortForwardPage extends StatefulWidget {
PortForwardPage({
Key? key,
required this.id,
required this.password,
required this.tabController,
required this.isRDP,
required this.isSharedPassword,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final DesktopTabController tabController;
final bool isRDP;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final SimpleWrapper<State<PortForwardPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _PortForwardPageState)._ffi;
@override
State<PortForwardPage> createState() {
final state = _PortForwardPageState();
_lastState.value = state;
return state;
}
}
class _PortForwardPageState extends State<PortForwardPage>
with AutomaticKeepAliveClientMixin {
final TextEditingController localPortController = TextEditingController();
final TextEditingController remoteHostController = TextEditingController();
final TextEditingController remotePortController = TextEditingController();
RxList<_PortForward> pfs = RxList.empty(growable: true);
late FFI _ffi;
@override
void initState() {
super.initState();
_ffi = FFI(null);
_ffi.start(widget.id,
isPortForward: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
isRdp: widget.isRDP);
Get.put<FFI>(_ffi, tag: 'pf_${widget.id}');
debugPrint("Port forward page init success with id ${widget.id}");
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id);
});
}
@override
void dispose() {
_ffi.close();
_ffi.dialogManager.dismissAll();
Get.delete<FFI>(tag: 'pf_${widget.id}');
super.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: FutureBuilder(future: () async {
if (!widget.isRDP) {
refreshTunnelConfig();
}
}(), builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return Container(
decoration: BoxDecoration(
border: Border.all(
width: 20,
color: Theme.of(context).scaffoldBackgroundColor)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildPrompt(context),
Flexible(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
border: Border.all(width: 1, color: MyTheme.border)),
child:
widget.isRDP ? buildRdp(context) : buildTunnel(context),
),
),
],
),
);
}
return const Offstage();
}),
);
}
buildPrompt(BuildContext context) {
return Obx(() => Offstage(
offstage: pfs.isEmpty && !widget.isRDP,
child: Container(
height: 45,
color: const Color(0xFF007F00),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
translate('Listening ...'),
style: const TextStyle(fontSize: 16, color: Colors.white),
),
Text(
translate('not_close_tcp_tip'),
style: const TextStyle(
fontSize: 10, color: Color(0xFFDDDDDD), height: 1.2),
)
])).marginOnly(bottom: 8),
));
}
buildTunnel(BuildContext context) {
text(String label) => Expanded(
child: Text(translate(label)).marginOnly(left: _kTextLeftMargin));
return Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme,
),
child: Obx(() => ListView.builder(
controller: ScrollController(),
itemCount: pfs.length + 2,
itemBuilder: ((context, index) {
if (index == 0) {
return Container(
height: 25,
color: Theme.of(context).scaffoldBackgroundColor,
child: Row(children: [
text('Local Port'),
const SizedBox(width: _kColumn1Width),
text('Remote Host'),
text('Remote Port'),
SizedBox(
width: _kColumn4Width, child: Text(translate('Action')))
]),
);
} else if (index == 1) {
return buildTunnelAddRow(context);
} else {
return buildTunnelDataRow(context, pfs[index - 2], index - 2);
}
}))),
);
}
buildTunnelAddRow(BuildContext context) {
var portInputFormatter = [
FilteringTextInputFormatter.allow(RegExp(
r'^([0-9]|[1-9]\d|[1-9]\d{2}|[1-9]\d{3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$'))
];
return Container(
height: _kRowHeight,
decoration:
BoxDecoration(color: Theme.of(context).colorScheme.background),
child: Row(children: [
buildTunnelInputCell(context,
controller: localPortController,
inputFormatters: portInputFormatter),
const SizedBox(
width: _kColumn1Width, child: Icon(Icons.arrow_forward_sharp)),
buildTunnelInputCell(context,
controller: remoteHostController, hint: 'localhost'),
buildTunnelInputCell(context,
controller: remotePortController,
inputFormatters: portInputFormatter),
ElevatedButton(
onPressed: () async {
int? localPort = int.tryParse(localPortController.text);
int? remotePort = int.tryParse(remotePortController.text);
if (localPort != null &&
remotePort != null &&
(remoteHostController.text.isEmpty ||
remoteHostController.text.trim().isNotEmpty)) {
await bind.sessionAddPortForward(
sessionId: _ffi.sessionId,
localPort: localPort,
remoteHost: remoteHostController.text.trim().isEmpty
? 'localhost'
: remoteHostController.text.trim(),
remotePort: remotePort);
localPortController.clear();
remoteHostController.clear();
remotePortController.clear();
refreshTunnelConfig();
}
},
child: Text(
translate('Add'),
),
).marginSymmetric(horizontal: 10),
]),
);
}
buildTunnelInputCell(BuildContext context,
{required TextEditingController controller,
List<TextInputFormatter>? inputFormatters,
String? hint}) {
return Expanded(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: TextField(
controller: controller,
inputFormatters: inputFormatters,
decoration: InputDecoration(
hintText: hint,
)).workaroundFreezeLinuxMint()),
);
}
Widget buildTunnelDataRow(BuildContext context, _PortForward pf, int index) {
text(String label) => Expanded(
child: Text(label, style: const TextStyle(fontSize: 20))
.marginOnly(left: _kTextLeftMargin));
return Container(
height: _kRowHeight,
decoration: BoxDecoration(
color: index % 2 == 0
? MyTheme.currentThemeMode() == ThemeMode.dark
? const Color(0xFF202020)
: const Color(0xFFF4F5F6)
: Theme.of(context).colorScheme.background),
child: Row(children: [
text(pf.localPort.toString()),
const SizedBox(width: _kColumn1Width),
text(pf.remoteHost),
text(pf.remotePort.toString()),
SizedBox(
width: _kColumn4Width,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () async {
await bind.sessionRemovePortForward(
sessionId: _ffi.sessionId, localPort: pf.localPort);
refreshTunnelConfig();
},
),
),
]),
);
}
void refreshTunnelConfig() async {
String peer = bind.mainGetPeerSync(id: widget.id);
Map<String, dynamic> config = jsonDecode(peer);
List<dynamic> infos = config['port_forwards'] as List;
List<_PortForward> result = List.empty(growable: true);
for (var e in infos) {
result.add(_PortForward.fromJson(e));
}
pfs.value = result;
}
buildRdp(BuildContext context) {
text1(String label) => Expanded(
child: Text(translate(label)).marginOnly(left: _kTextLeftMargin));
text2(String label) => Expanded(
child: Text(
label,
style: const TextStyle(fontSize: 20),
).marginOnly(left: _kTextLeftMargin));
return Theme(
data: Theme.of(context)
.copyWith(colorScheme: Theme.of(context).colorScheme),
child: ListView.builder(
controller: ScrollController(),
itemCount: 2,
itemBuilder: ((context, index) {
if (index == 0) {
return Container(
height: 25,
color: Theme.of(context).scaffoldBackgroundColor,
child: Row(children: [
text1('Local Port'),
const SizedBox(width: _kColumn1Width),
text1('Remote Host'),
text1('Remote Port'),
]),
);
} else {
return Container(
height: _kRowHeight,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background),
child: Row(children: [
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 120,
child: ElevatedButton(
onPressed: () =>
bind.sessionNewRdp(sessionId: _ffi.sessionId),
child: Text(
translate('New RDP'),
),
).marginSymmetric(vertical: 10),
).marginOnly(left: 20),
),
),
const SizedBox(
width: _kColumn1Width,
child: Icon(Icons.arrow_forward_sharp)),
text2('localhost'),
text2('RDP'),
]),
);
}
})),
);
}
@override
bool get wantKeepAlive => true;
}
@@ -0,0 +1,149 @@
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/pages/port_forward_page.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
class PortForwardTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const PortForwardTabPage({Key? key, required this.params}) : super(key: key);
@override
State<PortForwardTabPage> createState() => _PortForwardTabPageState(params);
}
class _PortForwardTabPageState extends State<PortForwardTabPage> {
late final DesktopTabController tabController;
late final bool isRDP;
static const IconData selectedIcon = Icons.forward_sharp;
static const IconData unselectedIcon = Icons.forward_outlined;
_PortForwardTabPageState(Map<String, dynamic> params) {
isRDP = params['isRDP'];
tabController =
Get.put(DesktopTabController(tabType: DesktopTabType.portForward));
tabController.onSelected = (id) {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.add(TabInfo(
key: params['id'],
label: params['id'],
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
page: PortForwardPage(
key: ValueKey(params['id']),
id: params['id'],
password: params['password'],
isSharedPassword: params['isSharedPassword'],
tabController: tabController,
isRDP: isRDP,
forceRelay: params['forceRelay'],
connToken: params['connToken'],
)));
}
@override
void initState() {
super.initState();
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
debugPrint(
"[Port Forward] call ${call.method} with args ${call.arguments} from window $fromWindowId");
// for simplify, just replace connectionId
if (call.method == kWindowEventNewPortForward) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final isRDP = args['isRDP'];
windowOnTop(windowId());
if (tabController.state.value.tabs.indexWhere((e) => e.key == id) >=
0) {
debugPrint("port forward $id exists");
return;
}
tabController.add(TabInfo(
key: id,
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
page: PortForwardPage(
key: ValueKey(args['id']),
id: id,
password: args['password'],
isSharedPassword: args['isSharedPassword'],
isRDP: isRDP,
tabController: tabController,
forceRelay: args['forceRelay'],
connToken: args['connToken'],
)));
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
}
});
Future.delayed(Duration.zero, () {
restoreWindowPosition(WindowType.PortForward, windowId: windowId());
});
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: () async {
tabController.clear();
return true;
},
tail: AddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
),
);
final tabWidget = isLinux
? buildVirtualWindowFrame(
context,
Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: child),
)
: workaroundWindowBorder(
context,
Container(
decoration: BoxDecoration(
border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: Obx(
() => SubWindowDragToResizeArea(
child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
),
);
}
void onRemoveId(String id) {
if (tabController.state.value.tabs.isEmpty) {
WindowController.fromWindowId(windowId()).close();
}
}
int windowId() {
return widget.params["windowId"];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,624 @@
import 'dart:convert';
import 'dart:async';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
as mod_menu;
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:bot_toast/bot_toast.dart';
import '../../common/widgets/dialog.dart';
import '../../models/platform_model.dart';
class _MenuTheme {
static const Color blueColor = MyTheme.button;
// kMinInteractiveDimension
static const double height = 20.0;
static const double dividerHeight = 12.0;
}
class ConnectionTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const ConnectionTabPage({Key? key, required this.params}) : super(key: key);
@override
State<ConnectionTabPage> createState() => _ConnectionTabPageState(params);
}
class _ConnectionTabPageState extends State<ConnectionTabPage> {
final tabController =
Get.put(DesktopTabController(tabType: DesktopTabType.remoteScreen));
final contentKey = UniqueKey();
static const IconData selectedIcon = Icons.desktop_windows_sharp;
static const IconData unselectedIcon = Icons.desktop_windows_outlined;
String? peerId;
bool _isScreenRectSet = false;
int? _display;
var connectionMap = RxList<Widget>.empty(growable: true);
_ConnectionTabPageState(Map<String, dynamic> params) {
RemoteCountState.init();
peerId = params['id'];
final sessionId = params['session_id'];
final tabWindowId = params['tab_window_id'];
final display = params['display'];
final displays = params['displays'];
final screenRect = parseParamScreenRect(params);
_isScreenRectSet = screenRect != null;
_display = display as int?;
tryMoveToScreenAndSetFullscreen(screenRect);
if (peerId != null) {
ConnectionTypeState.init(peerId!);
tabController.onSelected = (id) {
final remotePage = tabController.widget(id);
if (remotePage is RemotePage) {
final ffi = remotePage.ffi;
bind.setCurSessionId(sessionId: ffi.sessionId);
}
WindowController.fromWindowId(params['windowId'])
.setTitle(getWindowNameWithId(id));
UnreadChatCountState.find(id).value = 0;
};
tabController.add(TabInfo(
key: peerId!,
label: peerId!,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: peerId!,
tabController: tabController,
)) {
return;
}
tabController.closeBy(peerId!);
},
page: RemotePage(
key: ValueKey(peerId),
id: peerId!,
sessionId: sessionId == null ? null : SessionID(sessionId),
tabWindowId: tabWindowId,
display: display,
displays: displays?.cast<int>(),
password: params['password'],
toolbarState: ToolbarState(),
tabController: tabController,
switchUuid: params['switch_uuid'],
forceRelay: params['forceRelay'],
isSharedPassword: params['isSharedPassword'],
),
));
_update_remote_count();
}
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
}
@override
void initState() {
super.initState();
if (!_isScreenRectSet) {
Future.delayed(Duration.zero, () {
restoreWindowPosition(
WindowType.RemoteDesktop,
windowId: windowId(),
peerId: tabController.state.value.tabs.isEmpty
? null
: tabController.state.value.tabs[0].key,
display: _display,
);
});
}
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: Row(
mainAxisSize: MainAxisSize.min,
children: [
_RelativeMouseModeHint(tabController: tabController),
const AddButton(),
],
),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
tabBuilder: (key, icon, label, themeConf) => Obx(() {
final connectionType = ConnectionTypeState.find(key);
if (!connectionType.isValid()) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
label,
],
);
} else {
bool secure =
connectionType.secure.value == ConnectionType.strSecure;
bool direct =
connectionType.direct.value == ConnectionType.strDirect;
String msgConn = getConnectionText(
secure, direct, connectionType.stream_type.value);
var msgFingerprint = '${translate('Fingerprint')}:\n';
var fingerprint = FingerprintState.find(key).value;
if (fingerprint.isEmpty) {
fingerprint = 'N/A';
}
if (fingerprint.length > 5 * 8) {
var first = fingerprint.substring(0, 39);
var second = fingerprint.substring(40);
msgFingerprint += '$first\n$second';
} else {
msgFingerprint += fingerprint;
}
final tab = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
Tooltip(
message: '$msgConn\n$msgFingerprint',
child: SvgPicture.asset(
'assets/${connectionType.secure.value}${connectionType.direct.value}.svg',
width: themeConf.iconSize,
height: themeConf.iconSize,
).paddingOnly(right: 5),
),
label,
unreadMessageCountBuilder(UnreadChatCountState.find(key))
.marginOnly(left: 4),
],
);
return Listener(
onPointerDown: (e) {
if (e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final remotePage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == key)
.page as RemotePage;
if (remotePage.ffi.ffiModel.pi.isSet.isTrue && e.buttons == 2) {
showRightMenu(
(CancelFunc cancelFunc) {
return _tabMenuBuilder(key, cancelFunc);
},
target: e.position,
);
}
},
child: tab,
);
}
}),
),
);
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Obx(() => Container(
decoration: BoxDecoration(
border: Border.all(
color: MyTheme.color(context).border!,
width: stateGlobal.windowBorderWidth.value),
),
child: child,
)));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: Obx(() => SubWindowDragToResizeArea(
key: contentKey,
child: tabWidget,
// Specially configured for a better resize area and remote control.
childPadding: kDragToResizeAreaPadding,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
));
}
// Note: Some dup code to ../widgets/remote_toolbar
Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
final remotePage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == key)
.page as RemotePage;
final ffi = remotePage.ffi;
final pi = ffi.ffiModel.pi;
final perms = ffi.ffiModel.permissions;
final sessionId = ffi.sessionId;
final toolbarState = remotePage.toolbarState;
menu.addAll([
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
style: style,
)),
proc: () {
toolbarState.switchHide(sessionId);
cancelFunc();
},
padding: padding,
),
]);
if (tabController.state.value.tabs.length > 1) {
final splitAction = MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Move tab to new window'),
style: style,
),
proc: () async {
await DesktopMultiWindow.invokeMethod(
kMainWindowId,
kWindowEventMoveTabToNewWindow,
'${windowId()},$key,$sessionId,RemoteDesktop');
cancelFunc();
},
padding: padding,
);
menu.insert(1, splitAction);
}
if (perms['restart'] != false &&
(pi.platform == kPeerPlatformLinux ||
pi.platform == kPeerPlatformWindows ||
pi.platform == kPeerPlatformMacOS)) {
menu.add(MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Restart remote device'),
style: style,
),
proc: () => showRestartRemoteDevice(
pi, peerId ?? '', sessionId, ffi.dialogManager),
padding: padding,
dismissOnClicked: true,
dismissCallback: cancelFunc,
));
}
if (perms['keyboard'] != false && !ffi.ffiModel.viewOnly) {
menu.add(RemoteMenuEntry.insertLock(sessionId, padding,
dismissFunc: cancelFunc));
if (pi.platform == kPeerPlatformLinux || pi.sasEnabled) {
menu.add(RemoteMenuEntry.insertCtrlAltDel(sessionId, padding,
dismissFunc: cancelFunc));
}
}
menu.addAll([
MenuEntryDivider<String>(),
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Copy Fingerprint'),
style: style,
),
proc: () => onCopyFingerprint(FingerprintState.find(key).value),
padding: padding,
dismissOnClicked: true,
dismissCallback: cancelFunc,
),
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Close'),
style: style,
),
proc: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: key,
tabController: tabController,
)) {
return;
}
tabController.closeBy(key);
cancelFunc();
},
padding: padding,
)
]);
return mod_menu.PopupMenu<String>(
items: menu
.map((entry) => entry.build(
context,
const MenuConfig(
commonColor: _MenuTheme.blueColor,
height: _MenuTheme.height,
dividerHeight: _MenuTheme.dividerHeight,
)))
.expand((i) => i)
.toList(),
);
}
void onRemoveId(String id) async {
if (tabController.state.value.tabs.isEmpty) {
// Keep calling until the window status is hidden.
//
// Workaround for Windows:
// If you click other buttons and close in msgbox within a very short period of time, the close may fail.
// `await WindowController.fromWindowId(windowId()).close();`.
Future<void> loopCloseWindow() async {
int c = 0;
final windowController = WindowController.fromWindowId(windowId());
while (c < 20 &&
tabController.state.value.tabs.isEmpty &&
(!await windowController.isHidden())) {
await windowController.close();
await Future.delayed(Duration(milliseconds: 100));
c++;
}
}
loopCloseWindow();
}
ConnectionTypeState.delete(id);
// Clean up relative mouse mode state for this peer.
stateGlobal.relativeMouseModeState.remove(id);
_update_remote_count();
}
int windowId() {
return widget.params["windowId"];
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;
} else {
final bool res;
if (!option2bool(kOptionEnableConfirmClosingTabs,
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
res = true;
} else {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
}
return res;
}
}
_update_remote_count() =>
RemoteCountState.find().value = tabController.length;
Future<dynamic> _remoteMethodHandler(call, fromWindowId) async {
debugPrint(
"[Remote Page] call ${call.method} with args ${call.arguments} from window $fromWindowId");
dynamic returnValue;
// for simplify, just replace connectionId
if (call.method == kWindowEventNewRemoteDesktop) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final switchUuid = args['switch_uuid'];
final sessionId = args['session_id'];
final tabWindowId = args['tab_window_id'];
final display = args['display'];
final displays = args['displays'];
final screenRect = parseParamScreenRect(args);
final prePeerCount = tabController.length;
Future.delayed(Duration.zero, () async {
if (stateGlobal.fullscreen.isTrue) {
await WindowController.fromWindowId(windowId()).setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
}
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
WindowType.RemoteDesktop, display, screenRect);
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
await windowOnTop(windowId());
});
});
ConnectionTypeState.init(id);
tabController.add(TabInfo(
key: id,
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(id);
},
page: RemotePage(
key: ValueKey(id),
id: id,
sessionId: sessionId == null ? null : SessionID(sessionId),
tabWindowId: tabWindowId,
display: display,
displays: displays?.cast<int>(),
password: args['password'],
toolbarState: ToolbarState(),
tabController: tabController,
switchUuid: switchUuid,
forceRelay: args['forceRelay'],
isSharedPassword: args['isSharedPassword'],
),
));
} else if (call.method == kWindowDisableGrabKeyboard) {
// ???
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
final jumpOk = tabController.jumpToByKey(call.arguments);
if (jumpOk) {
windowOnTop(windowId());
}
return jumpOk;
} else if (call.method == kWindowEventActiveDisplaySession) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final display = args['display'];
final jumpOk = tabController.jumpToByKeyAndDisplay(id, display);
if (jumpOk) {
windowOnTop(windowId());
}
return jumpOk;
} else if (call.method == kWindowEventGetRemoteList) {
return tabController.state.value.tabs
.map((e) => e.key)
.toList()
.join(',');
} else if (call.method == kWindowEventGetSessionIdList) {
return tabController.state.value.tabs
.map((e) => '${e.key},${(e.page as RemotePage).ffi.sessionId}')
.toList()
.join(';');
} else if (call.method == kWindowEventGetCachedSessionData) {
// Ready to show new window and close old tab.
final args = jsonDecode(call.arguments);
final id = args['id'];
final close = args['close'];
try {
final remotePage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == id)
.page as RemotePage;
returnValue = remotePage.ffi.ffiModel.cachedPeerData.toString();
} catch (e) {
debugPrint('Failed to get cached session data: $e');
}
if (close && returnValue != null) {
closeSessionOnDispose[id] = false;
tabController.closeBy(id);
}
} else if (call.method == kWindowEventRemoteWindowCoords) {
final remotePage =
tabController.state.value.selectedTabInfo.page as RemotePage;
final ffi = remotePage.ffi;
final displayRect = ffi.ffiModel.displaysRect();
if (displayRect != null) {
final wc = WindowController.fromWindowId(windowId());
Rect? frame;
try {
frame = await wc.getFrame();
} catch (e) {
debugPrint(
"Failed to get frame of window $windowId, it may be hidden");
}
if (frame != null) {
ffi.cursorModel.moveLocal(0, 0);
final coords = RemoteWindowCoords(
frame,
CanvasCoords.fromCanvasModel(ffi.canvasModel),
CursorCoords.fromCursorModel(ffi.cursorModel),
displayRect);
returnValue = jsonEncode(coords.toJson());
}
}
} else if (call.method == kWindowEventSetFullscreen) {
stateGlobal.setFullscreen(call.arguments == 'true');
}
_update_remote_count();
return returnValue;
}
}
/// A widget that displays a hint in the tab bar when relative mouse mode is active.
/// This helps users remember how to exit relative mouse mode.
class _RelativeMouseModeHint extends StatelessWidget {
final DesktopTabController tabController;
const _RelativeMouseModeHint({Key? key, required this.tabController})
: super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
// Check if there are any tabs
if (tabController.state.value.tabs.isEmpty) {
return const SizedBox.shrink();
}
// Get current selected tab's RemotePage
final selectedTabInfo = tabController.state.value.selectedTabInfo;
if (selectedTabInfo.page is! RemotePage) {
return const SizedBox.shrink();
}
final remotePage = selectedTabInfo.page as RemotePage;
final String peerId = remotePage.id;
// Use global state to check relative mouse mode (synced from InputModel).
// This avoids timing issues with FFI registration.
final isRelativeMouseMode =
stateGlobal.relativeMouseModeState[peerId] ?? false;
if (!isRelativeMouseMode) {
return const SizedBox.shrink();
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(0.2),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.mouse,
size: 14,
color: Colors.orange[700],
),
const SizedBox(width: 4),
Text(
translate(
'rel-mouse-exit-{${isMacOS ? "Cmd+G" : "Ctrl+Alt"}}-tip'),
style: TextStyle(
fontSize: 11,
color: Colors.orange[700],
),
),
],
),
);
});
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../../models/model.dart';
/// Manages terminal connections to ensure one FFI instance per peer
class TerminalConnectionManager {
static final Map<String, FFI> _connections = {};
static final Map<String, int> _connectionRefCount = {};
// Track service IDs per peer
static final Map<String, String> _serviceIds = {};
/// Get or create an FFI instance for a peer
static FFI getConnection({
required String peerId,
required String? password,
required bool? isSharedPassword,
required bool? forceRelay,
required String? connToken,
}) {
final existingFfi = _connections[peerId];
if (existingFfi != null && !existingFfi.closed) {
// Increment reference count
_connectionRefCount[peerId] = (_connectionRefCount[peerId] ?? 0) + 1;
debugPrint('[TerminalConnectionManager] Reusing existing connection for peer $peerId. Reference count: ${_connectionRefCount[peerId]}');
return existingFfi;
}
// Create new FFI instance for first terminal
debugPrint('[TerminalConnectionManager] Creating new terminal connection for peer $peerId');
final ffi = FFI(null);
ffi.start(
peerId,
password: password,
isSharedPassword: isSharedPassword,
forceRelay: forceRelay,
connToken: connToken,
isTerminal: true,
);
_connections[peerId] = ffi;
_connectionRefCount[peerId] = 1;
// Register the FFI instance with Get for dependency injection
Get.put<FFI>(ffi, tag: 'terminal_$peerId');
debugPrint('[TerminalConnectionManager] New connection created. Total connections: ${_connections.length}');
return ffi;
}
/// Release a connection reference
static void releaseConnection(String peerId) {
final refCount = _connectionRefCount[peerId] ?? 0;
debugPrint('[TerminalConnectionManager] Releasing connection for peer $peerId. Current ref count: $refCount');
if (refCount <= 1) {
// Last reference, close the connection
final ffi = _connections[peerId];
if (ffi != null) {
debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)');
ffi.close();
_connections.remove(peerId);
_connectionRefCount.remove(peerId);
Get.delete<FFI>(tag: 'terminal_$peerId');
}
} else {
// Decrement reference count
_connectionRefCount[peerId] = refCount - 1;
debugPrint('[TerminalConnectionManager] Connection still in use. New ref count: ${_connectionRefCount[peerId]}');
}
}
/// Check if a connection exists for a peer
static bool hasConnection(String peerId) {
final ffi = _connections[peerId];
return ffi != null && !ffi.closed;
}
/// Get existing connection without creating new one
static FFI? getExistingConnection(String peerId) {
return _connections[peerId];
}
/// Get connection count for debugging
static int getConnectionCount() => _connections.length;
/// Get terminal count for a peer
static int getTerminalCount(String peerId) => _connectionRefCount[peerId] ?? 0;
/// Get service ID for a peer
static String? getServiceId(String peerId) => _serviceIds[peerId];
/// Set service ID for a peer
static void setServiceId(String peerId, String serviceId) {
_serviceIds[peerId] = serviceId;
debugPrint('[TerminalConnectionManager] Service ID for $peerId: $serviceId');
}
}
@@ -0,0 +1,223 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:xterm/xterm.dart';
import 'terminal_connection_manager.dart';
class TerminalPage extends StatefulWidget {
TerminalPage({
Key? key,
required this.id,
required this.password,
required this.tabController,
required this.isSharedPassword,
required this.terminalId,
required this.tabKey,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final DesktopTabController tabController;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
final String tabKey;
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
@override
State<TerminalPage> createState() {
final state = _TerminalPageState();
_lastState.value = state;
return state;
}
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin {
static const EdgeInsets _defaultTerminalPadding =
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
StreamSubscription<DesktopTabState>? _tabStateSubscription;
@override
void initState() {
super.initState();
// Listen for tab selection changes to request focus
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
);
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
// Enable focus once terminal has valid dimensions (first valid resize)
if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) {
_terminalFocusNode.canRequestFocus = true;
// Auto-focus if this tab is currently selected
_requestFocusIfSelected();
}
// Schedule the setState for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {});
}
});
};
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController.onSelected?.call(widget.id);
// Check if this is a new connection or additional terminal
// Note: When a connection exists, the ref count will be > 1 after this terminal is added
final isExistingConnection =
TerminalConnectionManager.hasConnection(widget.id) &&
TerminalConnectionManager.getTerminalCount(widget.id) > 1;
if (!isExistingConnection) {
// First terminal - show loading dialog, wait for onReady
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
} else {
// Additional terminal - connection already established
// Open the terminal directly
_terminalModel.openTerminal();
}
});
}
@override
void dispose() {
// Cancel tab state subscription to prevent memory leak
_tabStateSubscription?.cancel();
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_terminalFocusNode.dispose();
// Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id);
super.dispose();
}
void _onTabStateChanged(DesktopTabState state) {
// Check if this tab is now selected and request focus
if (state.selected >= 0 && state.selected < state.tabs.length) {
final selectedTab = state.tabs[state.selected];
if (selectedTab.key == widget.tabKey && mounted) {
_requestFocusIfSelected();
}
}
}
void _requestFocusIfSelected() {
if (!mounted || !_terminalFocusNode.canRequestFocus) return;
// Use post-frame callback to ensure widget is fully laid out in focus tree
WidgetsBinding.instance.addPostFrameCallback((_) {
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
final state = widget.tabController.state.value;
if (state.selected >= 0 && state.selected < state.tabs.length) {
if (state.tabs[state.selected].key == widget.tabKey) {
_terminalFocusNode.requestFocus();
}
}
});
}
// This method ensures that the number of visible rows is an integer by computing the
// extra space left after dividing the available height by the height of a single
// terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
EdgeInsets _calculatePadding(double heightPx) {
final cellHeight = _cellHeight;
if (!heightPx.isFinite ||
heightPx <= 0 ||
cellHeight == null ||
!cellHeight.isFinite ||
cellHeight <= 0) {
return _defaultTerminalPadding;
}
final rows = (heightPx / cellHeight).floor();
if (rows <= 0) {
return _defaultTerminalPadding;
}
final extraSpace = heightPx - rows * cellHeight;
if (!extraSpace.isFinite || extraSpace < 0) {
return _defaultTerminalPadding;
}
final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(
horizontal: _defaultTerminalPadding.horizontal / 2,
vertical: topBottom,
);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
focusNode: _terminalFocusNode,
// Note: autofocus is not used here because focus is managed manually
// via _onTabStateChanged() to handle tab switching properly.
backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
),
);
}
@override
bool get wantKeepAlive => true;
}
@@ -0,0 +1,625 @@
import 'dart:convert';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import '../../models/platform_model.dart';
import 'terminal_page.dart';
import 'terminal_connection_manager.dart';
import '../widgets/material_mod_popup_menu.dart' as mod_menu;
import '../widgets/popup_menu.dart';
import 'package:bot_toast/bot_toast.dart';
class TerminalTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const TerminalTabPage({Key? key, required this.params}) : super(key: key);
@override
State<TerminalTabPage> createState() => _TerminalTabPageState(params);
}
class _TerminalTabPageState extends State<TerminalTabPage> {
DesktopTabController get tabController => Get.find<DesktopTabController>();
static const IconData selectedIcon = Icons.terminal;
static const IconData unselectedIcon = Icons.terminal_outlined;
int _nextTerminalId = 1;
// Lightweight idempotency guard for async close operations
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
tabController.onSelected = (id) {
WindowController.fromWindowId(windowId())
.setTitle(getWindowNameWithId(id));
};
tabController.onRemoved = (_, id) => onRemoveId(id);
tabController.onCloseWindow = _closeWindowFromConnection;
final terminalId = params['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: params['id'],
terminalId: terminalId,
password: params['password'],
isSharedPassword: params['isSharedPassword'],
forceRelay: params['forceRelay'],
connToken: params['connToken'],
));
}
TabInfo _createTerminalTab({
required String peerId,
required int terminalId,
String? password,
bool? isSharedPassword,
bool? forceRelay,
String? connToken,
}) {
final tabKey = '${peerId}_$terminalId';
final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias');
final tabLabel =
alias.isNotEmpty ? '$alias #$terminalId' : '$peerId #$terminalId';
return TabInfo(
key: tabKey,
label: tabLabel,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () => _closeTab(tabKey),
page: TerminalPage(
key: ValueKey(tabKey),
id: peerId,
terminalId: terminalId,
tabKey: tabKey,
password: password,
isSharedPassword: isSharedPassword,
tabController: tabController,
forceRelay: forceRelay,
connToken: connToken,
),
);
}
/// Unified tab close handler for all close paths (button, shortcut, programmatic).
/// Shows audit dialog, cleans up session if not persistent, then removes the UI tab.
Future<void> _closeTab(String tabKey) async {
// Idempotency guard: skip if already closing this tab
if (_closingTabs.contains(tabKey)) return;
_closingTabs.add(tabKey);
try {
// Snapshot peerTabCount BEFORE any await to avoid race with concurrent
// _closeAllTabs clearing tabController (which would make the live count
// drop to 0 and incorrectly trigger session persistence).
// Note: the snapshot may become stale if other individual tabs are closed
// during the audit dialog, but this is an acceptable trade-off.
int? snapshotPeerTabCount;
final parsed = _parseTabKey(tabKey);
if (parsed != null) {
final (peerId, _) = parsed;
snapshotPeerTabCount = tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
}
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabKey,
tabController: tabController,
)) {
return;
}
// Close terminal session if not in persistent mode.
// Wrapped separately so session cleanup failure never blocks UI tab removal.
try {
await _closeTerminalSessionIfNeeded(tabKey,
peerTabCount: snapshotPeerTabCount);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
// Always close the tab from UI, regardless of session cleanup result
tabController.closeBy(tabKey);
} catch (e) {
debugPrint('[TerminalTabPage] Error closing tab $tabKey: $e');
} finally {
_closingTabs.remove(tabKey);
}
}
/// Close all tabs with session cleanup.
/// Used for window-level close operations (onDestroy, handleWindowCloseButton).
/// UI tabs are removed immediately; session cleanup runs in parallel with a
/// bounded timeout so window close is not blocked indefinitely.
Future<void> _closeAllTabs() async {
_windowClosing = true;
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
// Keep the cleanup target lookup below synchronous before its first await:
// it relies on the current frame still retaining each TerminalPage's FFI/model.
tabController.clear();
// Run session cleanup in parallel with bounded timeout (closeTerminal() has internal 3s timeout).
// Skip tabs already being closed by a concurrent _closeTab() to avoid duplicate FFI calls.
final futures = tabKeys
.where((tabKey) => !_closingTabs.contains(tabKey))
.map((tabKey) async {
try {
await _closeTerminalSessionIfNeeded(tabKey, persistAll: true);
} catch (e) {
debugPrint('[TerminalTabPage] Session cleanup failed for $tabKey: $e');
}
}).toList();
if (futures.isNotEmpty) {
await Future.wait(futures).timeout(
const Duration(seconds: 4),
onTimeout: () {
debugPrint(
'[TerminalTabPage] Session cleanup timed out for batch close');
return [];
},
);
}
}
/// Close the terminal session on server side based on persistent mode.
///
/// [persistAll] controls behavior when persistent mode is enabled:
/// - `true` (window close): persist all sessions, don't close any.
/// - `false` (tab close): only persist the last session for the peer,
/// close others so only the most recent disconnected session survives.
///
/// Note: if [_windowClosing] is true, persistAll is forced to true so that
/// in-flight _closeTab() calls don't accidentally close sessions that the
/// window-close flow intends to preserve.
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
{bool persistAll = false, int? peerTabCount}) async {
// If window close is in progress, override to persist all sessions
// even if this call originated from an individual tab close.
if (_windowClosing) {
persistAll = true;
}
final parsed = _parseTabKey(tabKey);
if (parsed == null) return;
final (peerId, terminalId) = parsed;
final ffi = TerminalConnectionManager.getExistingConnection(peerId);
if (ffi == null) return;
final isPersistent = bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
if (isPersistent) {
if (persistAll) {
// Window close: persist all sessions
return;
}
// Tab close: only persist if this is the last tab for this peer.
// Use the snapshot value if provided (avoids race with concurrent tab removal).
final effectivePeerTabCount = peerTabCount ??
tabController.state.value.tabs.where((t) {
final p = _parseTabKey(t.key);
return p != null && p.$1 == peerId;
}).length;
if (effectivePeerTabCount <= 1) {
// Last tab for this peer — persist the session
return;
}
// Not the last tab — fall through to close the session
}
final terminalModel = ffi.terminalModels[terminalId];
if (terminalModel != null) {
// closeTerminal() has internal 3s timeout, no need for external timeout
await terminalModel.closeTerminal();
}
}
/// Parse tabKey (format: "peerId_terminalId") into its components.
/// Note: peerId may contain underscores, so we use lastIndexOf('_').
/// Returns null if tabKey format is invalid.
(String peerId, int terminalId)? _parseTabKey(String tabKey) {
final lastUnderscore = tabKey.lastIndexOf('_');
if (lastUnderscore <= 0) {
debugPrint('[TerminalTabPage] Invalid tabKey format: $tabKey');
return null;
}
final terminalIdStr = tabKey.substring(lastUnderscore + 1);
final terminalId = int.tryParse(terminalIdStr);
if (terminalId == null) {
debugPrint('[TerminalTabPage] Invalid terminalId in tabKey: $tabKey');
return null;
}
final peerId = tabKey.substring(0, lastUnderscore);
return (peerId, terminalId);
}
Widget _tabMenuBuilder(String peerId, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
// New tab menu item
menu.add(MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('New tab'),
style: style,
),
proc: () {
_addNewTerminal(peerId);
cancelFunc();
// Also try to close any BotToast overlays
BotToast.cleanAll();
},
padding: padding,
));
menu.add(MenuEntryDivider());
menu.add(MenuEntrySwitch<String>(
switchType: SwitchType.scheckbox,
text: translate('Keep terminal sessions on disconnect'),
getter: () async {
final ffi = Get.find<FFI>(tag: 'terminal_$peerId');
return bind.sessionGetToggleOptionSync(
sessionId: ffi.sessionId,
arg: kOptionTerminalPersistent,
);
},
setter: (bool v) async {
final ffi = Get.find<FFI>(tag: 'terminal_$peerId');
await bind.sessionToggleOption(
sessionId: ffi.sessionId,
value: kOptionTerminalPersistent,
);
},
padding: padding,
));
return mod_menu.PopupMenu<String>(
items: menu
.map((e) => e.build(
context,
const MenuConfig(
commonColor: CustomPopupMenuTheme.commonColor,
height: CustomPopupMenuTheme.height,
dividerHeight: CustomPopupMenuTheme.dividerHeight,
),
))
.expand((i) => i)
.toList(),
);
}
@override
void initState() {
super.initState();
// Add keyboard shortcut handler
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
rustDeskWinManager.setMethodHandler((call, fromWindowId) async {
print(
"[Remote Terminal] call ${call.method} with args ${call.arguments} from window $fromWindowId");
if (call.method == kWindowEventNewTerminal) {
final args = jsonDecode(call.arguments);
final id = args['id'];
windowOnTop(windowId());
// Allow multiple terminals for the same connection
final terminalId = args['terminalId'] ?? _nextTerminalId++;
tabController.add(_createTerminalTab(
peerId: id,
terminalId: terminalId,
password: args['password'],
isSharedPassword: args['isSharedPassword'],
forceRelay: args['forceRelay'],
connToken: args['connToken'],
));
} else if (call.method == kWindowEventRestoreTerminalSessions) {
_restoreSessions(call.arguments);
} else if (call.method == "onDestroy") {
// Clean up sessions before window destruction (bounded wait)
await _closeAllTabs();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
if (tabController.state.value.tabs.isEmpty) {
return false;
}
final currentTab = tabController.state.value.selectedTabInfo;
assert(call.arguments is String,
"Expected String arguments for kWindowEventActiveSession, got ${call.arguments.runtimeType}");
// Use lastIndexOf to handle peerIds containing underscores
final lastUnderscore = currentTab.key.lastIndexOf('_');
if (lastUnderscore > 0 &&
currentTab.key.substring(0, lastUnderscore) == call.arguments) {
windowOnTop(windowId());
return true;
}
return false;
}
});
Future.delayed(Duration.zero, () {
restoreWindowPosition(WindowType.Terminal, windowId: windowId());
});
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
super.dispose();
}
Future<void> _restoreSessions(String arguments) async {
Map<String, dynamic>? args;
try {
args = jsonDecode(arguments) as Map<String, dynamic>;
} catch (e) {
debugPrint("Error parsing JSON arguments in _restoreSessions: $e");
return;
}
final persistentSessions =
args['persistent_sessions'] as List<dynamic>? ?? [];
final sortedSessions = persistentSessions.whereType<int>().toList()..sort();
var peerId = args['peer_id'] as String? ?? '';
if (peerId.isEmpty) {
if (tabController.state.value.tabs.isEmpty ||
tabController.state.value.selected >=
tabController.state.value.tabs.length) {
debugPrint('[TerminalTabPage] Skip restore: no selected tab');
return;
}
final currentTab = tabController.state.value.selectedTabInfo;
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
peerId = parsed.$1;
}
final existingTerminalIds = tabController.state.value.tabs
.map((tab) => _parseTabKey(tab.key))
.where((parsed) => parsed != null && parsed.$1 == peerId)
.map((parsed) => parsed!.$2)
.toSet();
if (existingTerminalIds.isEmpty) {
debugPrint(
'[TerminalTabPage] Skip restore: no seed tab for peer $peerId');
return;
}
for (final terminalId in sortedSessions) {
if (!existingTerminalIds.add(terminalId)) {
continue;
}
_addNewTerminal(peerId, terminalId: terminalId);
// A delay is required to ensure the UI has sufficient time to update
// before adding the next terminal. Without this delay, `_TerminalPageState::dispose()`
// may be called prematurely while the tab widget is still in the tab controller.
// This behavior is likely due to a race condition between the UI rendering lifecycle
// and the addition of new tabs. Attempts to use `_TerminalPageState::addPostFrameCallback()`
// to wait for the previous page to be ready were unsuccessful, as the observed call sequence is:
// `initState() 2 -> dispose() 2 -> postFrameCallback() 2`, followed by `initState() 3`.
// The `Future.delayed` approach mitigates this issue by introducing a buffer period,
// allowing the UI to stabilize before proceeding.
await Future.delayed(const Duration(milliseconds: 300));
}
}
bool _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
// Use Cmd+T on macOS, Ctrl+Shift+T on other platforms
if (event.logicalKey == LogicalKeyboardKey.keyT) {
if (isMacOS &&
HardwareKeyboard.instance.isMetaPressed &&
!HardwareKeyboard.instance.isShiftPressed) {
// macOS: Cmd+T (standard for new tab)
_addNewTerminalForCurrentPeer();
return true;
} else if (!isMacOS &&
HardwareKeyboard.instance.isControlPressed &&
HardwareKeyboard.instance.isShiftPressed) {
// Other platforms: Ctrl+Shift+T (to avoid conflict with Ctrl+T in terminal)
_addNewTerminalForCurrentPeer();
return true;
}
}
// Use Cmd+W on macOS, Ctrl+Shift+W on other platforms
if (event.logicalKey == LogicalKeyboardKey.keyW) {
if (isMacOS &&
HardwareKeyboard.instance.isMetaPressed &&
!HardwareKeyboard.instance.isShiftPressed) {
// macOS: Cmd+W (standard for close tab)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
_closeTab(currentTab.key);
return true;
}
} else if (!isMacOS &&
HardwareKeyboard.instance.isControlPressed &&
HardwareKeyboard.instance.isShiftPressed) {
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
final currentTab = tabController.state.value.selectedTabInfo;
if (tabController.state.value.tabs.length > 1) {
_closeTab(currentTab.key);
return true;
}
}
}
// Use Alt+Left/Right for tab navigation (avoids conflicts)
if (HardwareKeyboard.instance.isAltPressed) {
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
// Previous tab
final currentIndex = tabController.state.value.selected;
if (currentIndex > 0) {
tabController.jumpTo(currentIndex - 1);
}
return true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
// Next tab
final currentIndex = tabController.state.value.selected;
if (currentIndex < tabController.length - 1) {
tabController.jumpTo(currentIndex + 1);
}
return true;
}
}
// Check for Cmd/Ctrl + Number (switch to specific tab)
final numberKeys = [
LogicalKeyboardKey.digit1,
LogicalKeyboardKey.digit2,
LogicalKeyboardKey.digit3,
LogicalKeyboardKey.digit4,
LogicalKeyboardKey.digit5,
LogicalKeyboardKey.digit6,
LogicalKeyboardKey.digit7,
LogicalKeyboardKey.digit8,
LogicalKeyboardKey.digit9,
];
for (int i = 0; i < numberKeys.length; i++) {
if (event.logicalKey == numberKeys[i] &&
((isMacOS && HardwareKeyboard.instance.isMetaPressed) ||
(!isMacOS && HardwareKeyboard.instance.isControlPressed))) {
if (i < tabController.length) {
tabController.jumpTo(i);
return true;
}
}
}
}
return false;
}
void _addNewTerminal(String peerId, {int? terminalId}) {
// Find first tab for this peer to get connection parameters
final firstTab = tabController.state.value.tabs.firstWhere(
(tab) {
final last = tab.key.lastIndexOf('_');
return last > 0 && tab.key.substring(0, last) == peerId;
},
);
if (firstTab.page is TerminalPage) {
final page = firstTab.page as TerminalPage;
final newTerminalId = terminalId ?? _nextTerminalId++;
if (terminalId != null && terminalId >= _nextTerminalId) {
_nextTerminalId = terminalId + 1;
}
tabController.add(_createTerminalTab(
peerId: peerId,
terminalId: newTerminalId,
password: page.password,
isSharedPassword: page.isSharedPassword,
forceRelay: page.forceRelay,
connToken: page.connToken,
));
}
}
void _addNewTerminalForCurrentPeer({int? terminalId}) {
final currentTab = tabController.state.value.selectedTabInfo;
final parsed = _parseTabKey(currentTab.key);
if (parsed == null) return;
final (peerId, _) = parsed;
_addNewTerminal(peerId, terminalId: terminalId);
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).cardColor,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: _buildAddButton(),
selectedBorderColor: MyTheme.accent,
labelGetter: DesktopTab.tablabelGetter,
tabMenuBuilder: (key) {
final parsed = _parseTabKey(key);
if (parsed == null) return Container();
final (peerId, _) = parsed;
return _tabMenuBuilder(peerId, () {});
},
));
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Container(
decoration: BoxDecoration(
border: Border.all(color: MyTheme.color(context).border!)),
child: child,
));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: SubWindowDragToResizeArea(
child: tabWidget,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
);
}
void onRemoveId(String id) {
if (tabController.state.value.tabs.isEmpty) {
WindowController.fromWindowId(windowId()).close();
}
}
Future<void> _closeWindowFromConnection() async {
await _closeAllTabs();
await WindowController.fromWindowId(windowId()).close();
}
int windowId() {
return widget.params["windowId"];
}
Widget _buildAddButton() {
return ActionIcon(
message: 'New tab',
icon: IconFont.add,
onTap: () {
_addNewTerminalForCurrentPeer();
},
isClose: false,
);
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.state.value.tabs.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
await _closeAllTabs();
return true;
} else {
final bool res;
if (!option2bool(kOptionEnableConfirmClosingTabs,
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
res = true;
} else {
res = await closeConfirmDialog();
}
if (res) {
await _closeAllTabs();
}
return res;
}
}
}
@@ -0,0 +1,717 @@
import 'dart:async';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hbb/models/state_model.dart';
import '../../consts.dart';
import '../../common/widgets/overlay.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/toolbar.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../common/shared_state.dart';
import '../../utils/image.dart';
import '../widgets/remote_toolbar.dart';
import '../widgets/kb_layout_type_chooser.dart';
import '../widgets/tabbar_widget.dart';
import 'package:flutter_hbb/native/custom_cursor.dart'
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
final SimpleWrapper<bool> _firstEnterImage = SimpleWrapper(false);
// Used to skip session close if "move to new window" is clicked.
final Map<String, bool> closeSessionOnDispose = {};
class ViewCameraPage extends StatefulWidget {
ViewCameraPage({
Key? key,
required this.id,
required this.toolbarState,
this.sessionId,
this.tabWindowId,
this.password,
this.display,
this.displays,
this.tabController,
this.connToken,
this.forceRelay,
this.isSharedPassword,
}) : super(key: key) {
initSharedStates(id);
}
final String id;
final SessionID? sessionId;
final int? tabWindowId;
final int? display;
final List<int>? displays;
final String? password;
final ToolbarState toolbarState;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final SimpleWrapper<State<ViewCameraPage>?> _lastState = SimpleWrapper(null);
final DesktopTabController? tabController;
FFI get ffi => (_lastState.value! as _ViewCameraPageState)._ffi;
@override
State<ViewCameraPage> createState() {
final state = _ViewCameraPageState(id);
_lastState.value = state;
return state;
}
}
class _ViewCameraPageState extends State<ViewCameraPage>
with AutomaticKeepAliveClientMixin, MultiWindowListener {
Timer? _timer;
String keyboardMode = "legacy";
bool _isWindowBlur = false;
final _cursorOverImage = false.obs;
final _uniqueKey = UniqueKey();
var _blockableOverlayState = BlockableOverlayState();
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
// to identify the toolbar instance and its callback function.
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi;
SessionID get sessionId => _ffi.sessionId;
_ViewCameraPageState(String id) {
_initStates(id);
}
void _initStates(String id) {}
@override
void initState() {
super.initState();
_ffi = FFI(widget.sessionId);
Get.put<FFI>(_ffi, tag: widget.id);
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
showKBLayoutTypeChooserIfNeeded(
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
_ffi.recordingModel
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
});
_ffi.start(
widget.id,
isViewCamera: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
tabWindowId: widget.tabWindowId,
display: widget.display,
displays: widget.displays,
connToken: widget.connToken,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
WakelockManager.enable(_uniqueKey);
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
if (!isWeb) bind.pluginSyncUi(syncTo: kAppTypeDesktopRemote);
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
_ffi.dialogManager.loadMobileActionsOverlayVisible();
DesktopMultiWindow.addListener(this);
// if (!_isCustomCursorInited) {
// customCursorController.registerNeedUpdateCursorCallback(
// (String? lastKey, String? currentKey) async {
// if (_firstEnterImage.value) {
// _firstEnterImage.value = false;
// return true;
// }
// return lastKey == null || lastKey != currentKey;
// });
// _isCustomCursorInited = true;
// }
_blockableOverlayState.applyFfi(_ffi);
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.tabController?.onSelected?.call(widget.id);
});
}
@override
void onWindowBlur() {
super.onWindowBlur();
// On windows, we use `focus` way to handle keyboard better.
// Now on Linux, there's some rdev issues which will break the input.
// We disable the `focus` way for non-Windows temporarily.
if (isWindows) {
_isWindowBlur = true;
// unfocus the primary-focus when the whole window is lost focus,
// and let OS to handle events instead.
_rawKeyFocusNode.unfocus();
}
stateGlobal.isFocused.value = false;
}
@override
void onWindowFocus() {
super.onWindowFocus();
// See [onWindowBlur].
if (isWindows) {
_isWindowBlur = false;
}
stateGlobal.isFocused.value = true;
}
@override
void onWindowRestore() {
super.onWindowRestore();
// On windows, we use `onWindowRestore` way to handle window restore from
// a minimized state.
if (isWindows) {
_isWindowBlur = false;
}
WakelockManager.enable(_uniqueKey);
}
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
@override
void onWindowMaximize() {
super.onWindowMaximize();
WakelockManager.enable(_uniqueKey);
}
@override
void onWindowMinimize() {
super.onWindowMinimize();
WakelockManager.disable(_uniqueKey);
}
@override
void onWindowEnterFullScreen() {
super.onWindowEnterFullScreen();
if (isMacOS) {
stateGlobal.setFullscreen(true);
}
}
@override
void onWindowLeaveFullScreen() {
super.onWindowLeaveFullScreen();
if (isMacOS) {
stateGlobal.setFullscreen(false);
}
}
@override
Future<void> dispose() async {
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
// https://github.com/flutter/flutter/issues/64935
super.dispose();
debugPrint("VIEW CAMERA PAGE dispose session $sessionId ${widget.id}");
_ffi.textureModel.onViewCameraPageDispose(closeSession);
if (closeSession) {
// ensure we leave this session, this is a double check
_ffi.inputModel.enterOrLeave(false);
}
DesktopMultiWindow.removeListener(this);
_ffi.dialogManager.hideMobileActionsOverlay();
_ffi.imageModel.disposeImage();
_ffi.cursorModel.disposeImages();
_rawKeyFocusNode.dispose();
await _ffi.close(closeSession: closeSession);
_timer?.cancel();
_ffi.dialogManager.dismissAll();
if (closeSession) {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
}
WakelockManager.disable(_uniqueKey);
await Get.delete<FFI>(tag: widget.id);
removeSharedStates(widget.id);
}
Widget emptyOverlay() => BlockableOverlay(
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
/// see override build() in [BlockableOverlay]
state: _blockableOverlayState,
underlying: Container(
color: Colors.transparent,
),
);
Widget buildBody(BuildContext context) {
remoteToolbar(BuildContext context) => RemoteToolbar(
id: widget.id,
ffi: _ffi,
state: widget.toolbarState,
onEnterOrLeaveImageSetter: (id, func) {
_instanceIdOnEnterOrLeaveImage4Toolbar = id;
_onEnterOrLeaveImage4Toolbar = func;
},
onEnterOrLeaveImageCleaner: (id) {
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
_onEnterOrLeaveImage4Toolbar = null;
}
},
setRemoteState: setState,
);
bodyWidget() {
return Stack(
children: [
Container(
color: kColorCanvas,
child: getBodyForDesktop(context),
),
Stack(
children: [
_ffi.ffiModel.pi.isSet.isTrue &&
_ffi.ffiModel.waitForFirstImage.isTrue
? emptyOverlay()
: () {
if (!_ffi.ffiModel.isPeerAndroid) {
return Offstage();
} else {
return Obx(() => Offstage(
offstage: _ffi.dialogManager
.mobileActionsOverlayVisible.isFalse,
child: Overlay(initialEntries: [
makeMobileActionsOverlayEntry(
() => _ffi.dialogManager
.setMobileActionsOverlayVisible(false),
ffi: _ffi,
)
]),
));
}
}(),
// Use Overlay to enable rebuild every time on menu button click.
_ffi.ffiModel.pi.isSet.isTrue
? Overlay(
initialEntries: [OverlayEntry(builder: remoteToolbar)])
: remoteToolbar(context),
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
],
),
],
);
}
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: Obx(() {
final imageReady = _ffi.ffiModel.pi.isSet.isTrue &&
_ffi.ffiModel.waitForFirstImage.isFalse;
if (imageReady) {
// If the privacy mode(disable physical displays) is switched,
// we should not dismiss the dialog immediately.
if (DateTime.now().difference(togglePrivacyModeTime) >
const Duration(milliseconds: 3000)) {
// `dismissAll()` is to ensure that the state is clean.
// It's ok to call dismissAll() here.
_ffi.dialogManager.dismissAll();
// Recreate the block state to refresh the state.
_blockableOverlayState = BlockableOverlayState();
_blockableOverlayState.applyFfi(_ffi);
}
// Block the whole `bodyWidget()` when dialog shows.
return BlockableOverlay(
underlying: bodyWidget(),
state: _blockableOverlayState,
);
} else {
// `_blockableOverlayState` is not recreated here.
// The toolbar's block state won't work properly when reconnecting, but that's okay.
return bodyWidget();
}
}),
);
}
@override
Widget build(BuildContext context) {
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi);
return false;
},
child: MultiProvider(providers: [
ChangeNotifierProvider.value(value: _ffi.ffiModel),
ChangeNotifierProvider.value(value: _ffi.imageModel),
ChangeNotifierProvider.value(value: _ffi.cursorModel),
ChangeNotifierProvider.value(value: _ffi.canvasModel),
ChangeNotifierProvider.value(value: _ffi.recordingModel),
], child: buildBody(context)));
}
void enterView(PointerEnterEvent evt) {
_cursorOverImage.value = true;
_firstEnterImage.value = true;
if (_onEnterOrLeaveImage4Toolbar != null) {
try {
_onEnterOrLeaveImage4Toolbar!(true);
} catch (e) {
//
}
}
// See [onWindowBlur].
if (!isWindows) {
if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
_ffi.inputModel.enterOrLeave(true);
}
}
void leaveView(PointerExitEvent evt) {
if (_ffi.ffiModel.keyboard) {
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
}
_cursorOverImage.value = false;
_firstEnterImage.value = false;
if (_onEnterOrLeaveImage4Toolbar != null) {
try {
_onEnterOrLeaveImage4Toolbar!(false);
} catch (e) {
//
}
}
// See [onWindowBlur].
if (!isWindows) {
_ffi.inputModel.enterOrLeave(false);
}
}
Widget _buildRawTouchAndPointerRegion(
Widget child,
PointerEnterEventListener? onEnter,
PointerExitEventListener? onExit,
) {
return RawTouchGestureDetectorRegion(
child: _buildRawPointerMouseRegion(child, onEnter, onExit),
ffi: _ffi,
isCamera: true,
);
}
Widget _buildRawPointerMouseRegion(
Widget child,
PointerEnterEventListener? onEnter,
PointerExitEventListener? onExit,
) {
return CameraRawPointerMouseRegion(
onEnter: onEnter,
onExit: onExit,
onPointerDown: (event) {
// A double check for blur status.
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
// Sometimes the system does not send the necessary focus event to flutter. We should manually
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
// ensure the grab-key thread is running when our users are clicking the remote canvas.
if (_isWindowBlur) {
debugPrint(
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
_isWindowBlur = false;
}
if (!_rawKeyFocusNode.hasFocus) {
_rawKeyFocusNode.requestFocus();
}
},
inputModel: _ffi.inputModel,
child: child,
);
}
Widget getBodyForDesktop(BuildContext context) {
var paints = <Widget>[
MouseRegion(onEnter: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
}, onExit: (evt) {
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
}, child: LayoutBuilder(builder: (context, constraints) {
final c = Provider.of<CanvasModel>(context, listen: false);
Future.delayed(Duration.zero, () => c.updateViewStyle());
final peerDisplay = CurrentDisplayState.find(widget.id);
return Obx(
() => _ffi.ffiModel.pi.isSet.isFalse
? Container(color: Colors.transparent)
: Obx(() {
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
return ImagePaint(
id: widget.id,
cursorOverImage: _cursorOverImage,
listenerBuilder: (child) => _buildRawTouchAndPointerRegion(
child, enterView, leaveView),
ffi: _ffi,
);
}),
);
}))
];
paints.add(
Positioned(
top: 10,
right: 10,
child: _buildRawTouchAndPointerRegion(
QualityMonitor(_ffi.qualityMonitorModel), null, null),
),
);
return Stack(
children: paints,
);
}
@override
bool get wantKeepAlive => true;
}
class ImagePaint extends StatefulWidget {
final FFI ffi;
final String id;
final RxBool cursorOverImage;
final Widget Function(Widget)? listenerBuilder;
ImagePaint(
{Key? key,
required this.ffi,
required this.id,
required this.cursorOverImage,
this.listenerBuilder})
: super(key: key);
@override
State<StatefulWidget> createState() => _ImagePaintState();
}
class _ImagePaintState extends State<ImagePaint> {
String get id => widget.id;
RxBool get cursorOverImage => widget.cursorOverImage;
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);
var c = Provider.of<CanvasModel>(context);
final s = c.scale;
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
final paintWidth = c.getDisplayWidth() * s;
final paintHeight = c.getDisplayHeight() * s;
final paintSize = Size(paintWidth, paintHeight);
final paintWidget =
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
? _BuildPaintTextureRender(
c, s, Offset.zero, paintSize, isViewOriginal())
: _buildScrollbarNonTextureRender(m, paintSize, s);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
c.updateScrollPercent();
return false;
},
child: Container(
child: _buildCrossScrollbarFromLayout(
context,
_buildListener(paintWidget),
c.size,
paintSize,
c.scrollHorizontal,
c.scrollVertical,
)),
);
} else {
if (c.size.width > 0 && c.size.height > 0) {
final paintWidget =
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
? _BuildPaintTextureRender(
c,
s,
Offset(
isLinux ? c.x.toInt().toDouble() : c.x,
isLinux ? c.y.toInt().toDouble() : c.y,
),
c.size,
isViewOriginal())
: _buildScrollAutoNonTextureRender(m, c, s);
return Container(child: _buildListener(paintWidget));
} else {
return Container();
}
}
}
Widget _buildScrollbarNonTextureRender(
ImageModel m, Size imageSize, double s) {
return CustomPaint(
size: imageSize,
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s),
);
}
Widget _buildScrollAutoNonTextureRender(
ImageModel m, CanvasModel c, double s) {
return CustomPaint(
size: Size(c.size.width, c.size.height),
painter: ImagePainter(image: m.image, x: c.x / s, y: c.y / s, scale: s),
);
}
Widget _BuildPaintTextureRender(
CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) {
final ffiModel = c.parent.target!.ffiModel;
final displays = ffiModel.pi.getCurDisplays();
final children = <Widget>[];
final rect = ffiModel.rect;
if (rect == null) {
return Container();
}
final curDisplay = ffiModel.pi.currentDisplay;
for (var i = 0; i < displays.length; i++) {
final textureId = widget.ffi.textureModel
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
if (true) {
// both "textureId.value != -1" and "true" seems ok
children.add(Positioned(
left: (displays[i].x - rect.left) * s + offset.dx,
top: (displays[i].y - rect.top) * s + offset.dy,
width: displays[i].width * s,
height: displays[i].height * s,
child: Obx(() => Texture(
textureId: textureId.value,
filterQuality:
isViewOriginal ? FilterQuality.none : FilterQuality.low,
)),
));
}
}
return SizedBox(
width: size.width,
height: size.height,
child: Stack(children: children),
);
}
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
final cursor = Provider.of<CursorModel>(context);
final cache = cursor.cache ?? preDefaultCursor.cache;
return buildCursorOfCache(cursor, scale, cache);
}
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
final cursor = Provider.of<CursorModel>(context);
final cache = preForbiddenCursor.cache;
return buildCursorOfCache(cursor, scale, cache);
}
Widget _buildCrossScrollbarFromLayout(
BuildContext context,
Widget child,
Size layoutSize,
Size size,
ScrollController horizontal,
ScrollController vertical,
) {
var widget = child;
if (layoutSize.width < size.width) {
widget = ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: horizontal,
scrollDirection: Axis.horizontal,
physics: cursorOverImage.isTrue
? const NeverScrollableScrollPhysics()
: null,
child: widget,
),
);
} else {
widget = Row(
children: [
Container(
width: ((layoutSize.width - size.width) ~/ 2).toDouble(),
),
widget,
],
);
}
if (layoutSize.height < size.height) {
widget = ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
child: SingleChildScrollView(
controller: vertical,
physics: cursorOverImage.isTrue
? const NeverScrollableScrollPhysics()
: null,
child: widget,
),
);
} else {
widget = Column(
children: [
Container(
height: ((layoutSize.height - size.height) ~/ 2).toDouble(),
),
widget,
],
);
}
if (layoutSize.width < size.width) {
widget = RawScrollbar(
thickness: kScrollbarThickness,
thumbColor: Colors.grey,
controller: horizontal,
thumbVisibility: false,
trackVisibility: false,
notificationPredicate: layoutSize.height < size.height
? (notification) => notification.depth == 1
: defaultScrollNotificationPredicate,
child: widget,
);
}
if (layoutSize.height < size.height) {
widget = RawScrollbar(
thickness: kScrollbarThickness,
thumbColor: Colors.grey,
controller: vertical,
thumbVisibility: false,
trackVisibility: false,
child: widget,
);
}
return Container(
child: widget,
width: layoutSize.width,
height: layoutSize.height,
);
}
Widget _buildListener(Widget child) {
if (listenerBuilder != null) {
return listenerBuilder!(child);
} else {
return child;
}
}
}
@@ -0,0 +1,522 @@
import 'dart:convert';
import 'dart:async';
import 'dart:ui' as ui;
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/desktop/pages/view_camera_page.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
import 'package:flutter_hbb/desktop/widgets/material_mod_popup_menu.dart'
as mod_menu;
import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:get/get.dart';
import 'package:bot_toast/bot_toast.dart';
import '../../models/platform_model.dart';
class _MenuTheme {
static const Color blueColor = MyTheme.button;
// kMinInteractiveDimension
static const double height = 20.0;
static const double dividerHeight = 12.0;
}
class ViewCameraTabPage extends StatefulWidget {
final Map<String, dynamic> params;
const ViewCameraTabPage({Key? key, required this.params}) : super(key: key);
@override
State<ViewCameraTabPage> createState() => _ViewCameraTabPageState(params);
}
class _ViewCameraTabPageState extends State<ViewCameraTabPage> {
final tabController =
Get.put(DesktopTabController(tabType: DesktopTabType.viewCamera));
final contentKey = UniqueKey();
static const IconData selectedIcon = Icons.desktop_windows_sharp;
static const IconData unselectedIcon = Icons.desktop_windows_outlined;
String? peerId;
bool _isScreenRectSet = false;
int? _display;
var connectionMap = RxList<Widget>.empty(growable: true);
_ViewCameraTabPageState(Map<String, dynamic> params) {
RemoteCountState.init();
peerId = params['id'];
final sessionId = params['session_id'];
final tabWindowId = params['tab_window_id'];
final display = params['display'];
final displays = params['displays'];
final screenRect = parseParamScreenRect(params);
_isScreenRectSet = screenRect != null;
_display = display as int?;
tryMoveToScreenAndSetFullscreen(screenRect);
if (peerId != null) {
ConnectionTypeState.init(peerId!);
tabController.onSelected = (id) {
final viewCameraPage = tabController.widget(id);
if (viewCameraPage is ViewCameraPage) {
final ffi = viewCameraPage.ffi;
bind.setCurSessionId(sessionId: ffi.sessionId);
}
WindowController.fromWindowId(params['windowId'])
.setTitle(getWindowNameWithId(id));
UnreadChatCountState.find(id).value = 0;
};
tabController.add(TabInfo(
key: peerId!,
label: peerId!,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: peerId!,
tabController: tabController,
)) {
return;
}
tabController.closeBy(peerId!);
},
page: ViewCameraPage(
key: ValueKey(peerId),
id: peerId!,
sessionId: sessionId == null ? null : SessionID(sessionId),
tabWindowId: tabWindowId,
display: display,
displays: displays?.cast<int>(),
password: params['password'],
toolbarState: ToolbarState(),
tabController: tabController,
connToken: params['connToken'],
forceRelay: params['forceRelay'],
isSharedPassword: params['isSharedPassword'],
),
));
_update_remote_count();
}
tabController.onRemoved = (_, id) => onRemoveId(id);
rustDeskWinManager.setMethodHandler(_remoteMethodHandler);
}
@override
void initState() {
super.initState();
if (!_isScreenRectSet) {
Future.delayed(Duration.zero, () {
restoreWindowPosition(
WindowType.ViewCamera,
windowId: windowId(),
peerId: tabController.state.value.tabs.isEmpty
? null
: tabController.state.value.tabs[0].key,
display: _display,
);
});
}
}
@override
Widget build(BuildContext context) {
final child = Scaffold(
backgroundColor: Theme.of(context).colorScheme.background,
body: DesktopTab(
controller: tabController,
onWindowCloseButton: handleWindowCloseButton,
tail: const AddButton(),
selectedBorderColor: MyTheme.accent,
pageViewBuilder: (pageView) => pageView,
labelGetter: DesktopTab.tablabelGetter,
tabBuilder: (key, icon, label, themeConf) => Obx(() {
final connectionType = ConnectionTypeState.find(key);
if (!connectionType.isValid()) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
label,
],
);
} else {
bool secure =
connectionType.secure.value == ConnectionType.strSecure;
bool direct =
connectionType.direct.value == ConnectionType.strDirect;
String msgConn = getConnectionText(
secure, direct, connectionType.stream_type.value);
var msgFingerprint = '${translate('Fingerprint')}:\n';
var fingerprint = FingerprintState.find(key).value;
if (fingerprint.isEmpty) {
fingerprint = 'N/A';
}
if (fingerprint.length > 5 * 8) {
var first = fingerprint.substring(0, 39);
var second = fingerprint.substring(40);
msgFingerprint += '$first\n$second';
} else {
msgFingerprint += fingerprint;
}
final tab = Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
icon,
Tooltip(
message: '$msgConn\n$msgFingerprint',
child: SvgPicture.asset(
'assets/${connectionType.secure.value}${connectionType.direct.value}.svg',
width: themeConf.iconSize,
height: themeConf.iconSize,
).paddingOnly(right: 5),
),
label,
unreadMessageCountBuilder(UnreadChatCountState.find(key))
.marginOnly(left: 4),
],
);
return Listener(
onPointerDown: (e) {
if (e.kind != ui.PointerDeviceKind.mouse) {
return;
}
final viewCameraPage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == key)
.page as ViewCameraPage;
if (viewCameraPage.ffi.ffiModel.pi.isSet.isTrue &&
e.buttons == 2) {
showRightMenu(
(CancelFunc cancelFunc) {
return _tabMenuBuilder(key, cancelFunc);
},
target: e.position,
);
}
},
child: tab,
);
}
}),
),
);
final tabWidget = isLinux
? buildVirtualWindowFrame(context, child)
: workaroundWindowBorder(
context,
Obx(() => Container(
decoration: BoxDecoration(
border: Border.all(
color: MyTheme.color(context).border!,
width: stateGlobal.windowBorderWidth.value),
),
child: child,
)));
return isMacOS || kUseCompatibleUiMode
? tabWidget
: Obx(() => SubWindowDragToResizeArea(
key: contentKey,
child: tabWidget,
// Specially configured for a better resize area and remote control.
childPadding: kDragToResizeAreaPadding,
resizeEdgeSize: stateGlobal.resizeEdgeSize.value,
enableResizeEdges: subWindowManagerEnableResizeEdges,
windowId: stateGlobal.windowId,
));
}
// Note: Some dup code to ../widgets/remote_toolbar
Widget _tabMenuBuilder(String key, CancelFunc cancelFunc) {
final List<MenuEntryBase<String>> menu = [];
const EdgeInsets padding = EdgeInsets.only(left: 8.0, right: 5.0);
final viewCameraPage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == key)
.page as ViewCameraPage;
final ffi = viewCameraPage.ffi;
final sessionId = ffi.sessionId;
final toolbarState = viewCameraPage.toolbarState;
menu.addAll([
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Obx(() => Text(
translate(
toolbarState.hide.isTrue ? 'Show Toolbar' : 'Hide Toolbar'),
style: style,
)),
proc: () {
toolbarState.switchHide(sessionId);
cancelFunc();
},
padding: padding,
),
]);
if (tabController.state.value.tabs.length > 1) {
final splitAction = MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Move tab to new window'),
style: style,
),
proc: () async {
await DesktopMultiWindow.invokeMethod(
kMainWindowId,
kWindowEventMoveTabToNewWindow,
'${windowId()},$key,$sessionId,ViewCamera');
cancelFunc();
},
padding: padding,
);
menu.insert(1, splitAction);
}
menu.addAll([
MenuEntryDivider<String>(),
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Copy Fingerprint'),
style: style,
),
proc: () => onCopyFingerprint(FingerprintState.find(key).value),
padding: padding,
dismissOnClicked: true,
dismissCallback: cancelFunc,
),
MenuEntryButton<String>(
childBuilder: (TextStyle? style) => Text(
translate('Close'),
style: style,
),
proc: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: key,
tabController: tabController,
)) {
return;
}
tabController.closeBy(key);
cancelFunc();
},
padding: padding,
)
]);
return mod_menu.PopupMenu<String>(
items: menu
.map((entry) => entry.build(
context,
const MenuConfig(
commonColor: _MenuTheme.blueColor,
height: _MenuTheme.height,
dividerHeight: _MenuTheme.dividerHeight,
)))
.expand((i) => i)
.toList(),
);
}
void onRemoveId(String id) async {
if (tabController.state.value.tabs.isEmpty) {
// Keep calling until the window status is hidden.
//
// Workaround for Windows:
// If you click other buttons and close in msgbox within a very short period of time, the close may fail.
// `await WindowController.fromWindowId(windowId()).close();`.
Future<void> loopCloseWindow() async {
int c = 0;
final windowController = WindowController.fromWindowId(windowId());
while (c < 20 &&
tabController.state.value.tabs.isEmpty &&
(!await windowController.isHidden())) {
await windowController.close();
await Future.delayed(Duration(milliseconds: 100));
c++;
}
}
loopCloseWindow();
}
ConnectionTypeState.delete(id);
_update_remote_count();
}
int windowId() {
return widget.params["windowId"];
}
Future<bool> handleWindowCloseButton() async {
final connLength = tabController.length;
if (connLength == 1) {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: tabController.state.value.tabs[0].key,
tabController: tabController,
)) {
return false;
}
}
if (connLength <= 1) {
tabController.clear();
return true;
} else {
final bool res;
if (!option2bool(kOptionEnableConfirmClosingTabs,
bind.mainGetLocalOption(key: kOptionEnableConfirmClosingTabs))) {
res = true;
} else {
res = await closeConfirmDialog();
}
if (res) {
tabController.clear();
}
return res;
}
}
_update_remote_count() =>
RemoteCountState.find().value = tabController.length;
Future<dynamic> _remoteMethodHandler(call, fromWindowId) async {
debugPrint(
"[View Camera Page] call ${call.method} with args ${call.arguments} from window $fromWindowId");
dynamic returnValue;
// for simplify, just replace connectionId
if (call.method == kWindowEventNewViewCamera) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final sessionId = args['session_id'];
final tabWindowId = args['tab_window_id'];
final display = args['display'];
final displays = args['displays'];
final screenRect = parseParamScreenRect(args);
final prePeerCount = tabController.length;
Future.delayed(Duration.zero, () async {
if (stateGlobal.fullscreen.isTrue) {
await WindowController.fromWindowId(windowId()).setFullscreen(false);
stateGlobal.setFullscreen(false, procWnd: false);
}
await setNewConnectWindowFrame(windowId(), id!, prePeerCount,
WindowType.ViewCamera, display, screenRect);
Future.delayed(Duration(milliseconds: isWindows ? 100 : 0), () async {
await windowOnTop(windowId());
});
});
ConnectionTypeState.init(id);
tabController.add(TabInfo(
key: id,
label: id,
selectedIcon: selectedIcon,
unselectedIcon: unselectedIcon,
onTabCloseButton: () async {
if (await desktopTryShowTabAuditDialogCloseCancelled(
id: id,
tabController: tabController,
)) {
return;
}
tabController.closeBy(id);
},
page: ViewCameraPage(
key: ValueKey(id),
id: id,
sessionId: sessionId == null ? null : SessionID(sessionId),
tabWindowId: tabWindowId,
display: display,
displays: displays?.cast<int>(),
password: args['password'],
toolbarState: ToolbarState(),
tabController: tabController,
connToken: args['connToken'],
forceRelay: args['forceRelay'],
isSharedPassword: args['isSharedPassword'],
),
));
} else if (call.method == kWindowDisableGrabKeyboard) {
// ???
} else if (call.method == "onDestroy") {
tabController.clear();
} else if (call.method == kWindowActionRebuild) {
reloadCurrentWindow();
} else if (call.method == kWindowEventActiveSession) {
final jumpOk = tabController.jumpToByKey(call.arguments);
if (jumpOk) {
windowOnTop(windowId());
}
return jumpOk;
} else if (call.method == kWindowEventActiveDisplaySession) {
final args = jsonDecode(call.arguments);
final id = args['id'];
final display = args['display'];
final jumpOk =
tabController.jumpToByKeyAndDisplay(id, display, isCamera: true);
if (jumpOk) {
windowOnTop(windowId());
}
return jumpOk;
} else if (call.method == kWindowEventGetRemoteList) {
return tabController.state.value.tabs
.map((e) => e.key)
.toList()
.join(',');
} else if (call.method == kWindowEventGetSessionIdList) {
return tabController.state.value.tabs
.map((e) => '${e.key},${(e.page as ViewCameraPage).ffi.sessionId}')
.toList()
.join(';');
} else if (call.method == kWindowEventGetCachedSessionData) {
// Ready to show new window and close old tab.
final args = jsonDecode(call.arguments);
final id = args['id'];
final close = args['close'];
try {
final viewCameraPage = tabController.state.value.tabs
.firstWhere((tab) => tab.key == id)
.page as ViewCameraPage;
returnValue = viewCameraPage.ffi.ffiModel.cachedPeerData.toString();
} catch (e) {
debugPrint('Failed to get cached session data: $e');
}
if (close && returnValue != null) {
closeSessionOnDispose[id] = false;
tabController.closeBy(id);
}
} else if (call.method == kWindowEventRemoteWindowCoords) {
final viewCameraPage =
tabController.state.value.selectedTabInfo.page as ViewCameraPage;
final ffi = viewCameraPage.ffi;
final displayRect = ffi.ffiModel.displaysRect();
if (displayRect != null) {
final wc = WindowController.fromWindowId(windowId());
Rect? frame;
try {
frame = await wc.getFrame();
} catch (e) {
debugPrint(
"Failed to get frame of window $windowId, it may be hidden");
}
if (frame != null) {
ffi.cursorModel.moveLocal(0, 0);
final coords = RemoteWindowCoords(
frame,
CanvasCoords.fromCanvasModel(ffi.canvasModel),
CursorCoords.fromCursorModel(ffi.cursorModel),
displayRect);
returnValue = jsonEncode(coords.toJson());
}
}
} else if (call.method == kWindowEventSetFullscreen) {
stateGlobal.setFullscreen(call.arguments == 'true');
}
_update_remote_count();
return returnValue;
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/pages/file_manager_tab_page.dart';
import 'package:provider/provider.dart';
/// multi-tab file transfer remote screen
class DesktopFileTransferScreen extends StatelessWidget {
final Map<String, dynamic> params;
const DesktopFileTransferScreen({Key? key, required this.params})
: super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
ChangeNotifierProvider.value(value: gFFI.imageModel),
ChangeNotifierProvider.value(value: gFFI.cursorModel),
ChangeNotifierProvider.value(value: gFFI.canvasModel),
],
child: Scaffold(
backgroundColor: isLinux ? Colors.transparent : null,
body: FileManagerTabPage(
params: params,
),
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/pages/port_forward_tab_page.dart';
import 'package:provider/provider.dart';
/// multi-tab file port forward screen
class DesktopPortForwardScreen extends StatelessWidget {
final Map<String, dynamic> params;
const DesktopPortForwardScreen({Key? key, required this.params})
: super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
],
child: Scaffold(
backgroundColor: isLinux ? Colors.transparent : null,
body: PortForwardTabPage(
params: params,
),
),
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/pages/remote_tab_page.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:provider/provider.dart';
/// multi-tab desktop remote screen
class DesktopRemoteScreen extends StatelessWidget {
final Map<String, dynamic> params;
DesktopRemoteScreen({Key? key, required this.params}) : super(key: key) {
bind.mainInitInputSource();
stateGlobal.getInputSource(force: true);
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
ChangeNotifierProvider.value(value: gFFI.imageModel),
ChangeNotifierProvider.value(value: gFFI.cursorModel),
ChangeNotifierProvider.value(value: gFFI.canvasModel),
],
child: Scaffold(
// Set transparent background for padding the resize area out of the flutter view.
// This allows the wallpaper goes through our resize area. (Linux only now).
backgroundColor: isLinux ? Colors.transparent : null,
body: ConnectionTabPage(
params: params,
),
));
}
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:provider/provider.dart';
import 'package:flutter_hbb/desktop/pages/terminal_tab_page.dart';
class DesktopTerminalScreen extends StatelessWidget {
final Map<String, dynamic> params;
const DesktopTerminalScreen({Key? key, required this.params})
: super(key: key);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
],
child: Scaffold(
backgroundColor: isLinux ? Colors.transparent : null,
body: TerminalTabPage(
params: params,
),
),
);
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/desktop/pages/view_camera_tab_page.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:provider/provider.dart';
/// multi-tab desktop remote screen
class DesktopViewCameraScreen extends StatelessWidget {
final Map<String, dynamic> params;
DesktopViewCameraScreen({Key? key, required this.params}) : super(key: key) {
bind.mainInitInputSource();
stateGlobal.getInputSource(force: true);
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: gFFI.ffiModel),
ChangeNotifierProvider.value(value: gFFI.imageModel),
ChangeNotifierProvider.value(value: gFFI.cursorModel),
ChangeNotifierProvider.value(value: gFFI.canvasModel),
],
child: Scaffold(
// Set transparent background for padding the resize area out of the flutter view.
// This allows the wallpaper goes through our resize area. (Linux only now).
backgroundColor: isLinux ? Colors.transparent : null,
body: ViewCameraTabPage(
params: params,
),
));
}
}
+171
View File
@@ -0,0 +1,171 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
class Button extends StatefulWidget {
final GestureTapCallback onTap;
final String text;
final double? textSize;
final double? minWidth;
final bool isOutline;
final double? padding;
final Color? textColor;
final double? radius;
final Color? borderColor;
Button({
Key? key,
this.minWidth,
this.isOutline = false,
this.textSize,
this.padding,
this.textColor,
this.radius,
this.borderColor,
required this.onTap,
required this.text,
}) : super(key: key);
@override
State<Button> createState() => _ButtonState();
}
class _ButtonState extends State<Button> {
RxBool hover = false.obs;
RxBool pressed = false.obs;
@override
Widget build(BuildContext context) {
return Obx(() => InkWell(
onTapDown: (_) => pressed.value = true,
onTapUp: (_) => pressed.value = false,
onTapCancel: () => pressed.value = false,
onHover: (value) => hover.value = value,
onTap: widget.onTap,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: widget.minWidth ?? 70.0,
),
child: Container(
padding: EdgeInsets.all(widget.padding ?? 4.5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: pressed.value
? MyTheme.accent
: (widget.isOutline
? Colors.transparent
: MyTheme.button),
border: Border.all(
color: pressed.value
? MyTheme.accent
: hover.value
? MyTheme.hoverBorder
: (widget.isOutline
? widget.borderColor ?? MyTheme.border
: MyTheme.button),
),
borderRadius: BorderRadius.circular(widget.radius ?? 5),
),
child: Text(
translate(
widget.text,
),
style: TextStyle(
fontSize: widget.textSize ?? 12.0,
color: widget.isOutline
? widget.textColor ??
Theme.of(context).textTheme.titleLarge?.color
: Colors.white),
).marginSymmetric(horizontal: 12),
)),
));
}
}
class FixedWidthButton extends StatefulWidget {
final GestureTapCallback onTap;
final String text;
final double? textSize;
final double width;
final bool isOutline;
final double? padding;
final Color? textColor;
final double? radius;
final Color? borderColor;
final int? maxLines;
FixedWidthButton({
Key? key,
required this.width,
this.maxLines,
this.isOutline = false,
this.textSize,
this.padding,
this.textColor,
this.radius,
this.borderColor,
required this.onTap,
required this.text,
}) : super(key: key);
@override
State<FixedWidthButton> createState() => _FixedWidthButtonState();
}
class _FixedWidthButtonState extends State<FixedWidthButton> {
RxBool hover = false.obs;
RxBool pressed = false.obs;
@override
Widget build(BuildContext context) {
return Obx(() => InkWell(
onTapDown: (_) => pressed.value = true,
onTapUp: (_) => pressed.value = false,
onTapCancel: () => pressed.value = false,
onHover: (value) => hover.value = value,
onTap: widget.onTap,
child: Container(
width: widget.width,
padding: EdgeInsets.all(widget.padding ?? 4.5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: pressed.value
? MyTheme.accent
: (widget.isOutline ? Colors.transparent : MyTheme.button),
border: Border.all(
color: pressed.value
? MyTheme.accent
: hover.value
? MyTheme.hoverBorder
: (widget.isOutline
? widget.borderColor ?? MyTheme.border
: MyTheme.button),
),
borderRadius: BorderRadius.circular(widget.radius ?? 5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: AutoSizeText(
translate(
widget.text,
),
maxLines: widget.maxLines ?? 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: widget.textSize ?? 12.0,
color: widget.isOutline
? widget.textColor ??
Theme.of(context).textTheme.titleLarge?.color
: Colors.white),
).marginSymmetric(horizontal: 12),
),
],
),
),
));
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class DraggableDivider extends StatefulWidget {
final Axis axis;
final double thickness;
final Color color;
final Function(double)? onPointerMove;
final VoidCallback? onHover;
final EdgeInsets padding;
const DraggableDivider({
super.key,
this.axis = Axis.horizontal,
this.thickness = 1.0,
this.color = const Color.fromARGB(200, 177, 175, 175),
this.onPointerMove,
this.padding = const EdgeInsets.symmetric(horizontal: 1.0),
this.onHover,
});
@override
State<DraggableDivider> createState() => _DraggableDividerState();
}
class _DraggableDividerState extends State<DraggableDivider> {
@override
Widget build(BuildContext context) {
return Listener(
onPointerMove: (event) {
final dl = widget.axis == Axis.horizontal
? event.localDelta.dy
: event.localDelta.dx;
widget.onPointerMove?.call(dl);
},
onPointerHover: (event) => widget.onHover?.call(),
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: Padding(
padding: widget.padding,
child: Container(
decoration: BoxDecoration(color: widget.color),
width: widget.axis == Axis.horizontal
? double.infinity
: widget.thickness,
height: widget.axis == Axis.horizontal
? widget.thickness
: double.infinity,
),
),
),
);
}
}
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import '../../common.dart';
typedef KBChosenCallback = Future<bool> Function(String);
const double _kImageMarginVertical = 6.0;
const double _kImageMarginHorizontal = 10.0;
const double _kImageBoarderWidth = 4.0;
const double _kImagePaddingWidth = 4.0;
const Color _kImageBorderColor = Color.fromARGB(125, 202, 247, 2);
const double _kBorderRadius = 6.0;
const String _kKBLayoutTypeISO = 'ISO';
const String _kKBLayoutTypeNotISO = 'Not ISO';
const _kKBLayoutImageMap = {
_kKBLayoutTypeISO: 'kb_layout_iso',
_kKBLayoutTypeNotISO: 'kb_layout_not_iso',
};
class _KBImage extends StatelessWidget {
final String kbLayoutType;
final double imageWidth;
final RxString chosenType;
const _KBImage({
Key? key,
required this.kbLayoutType,
required this.imageWidth,
required this.chosenType,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kBorderRadius),
border: Border.all(
color: chosenType.value == kbLayoutType
? _kImageBorderColor
: Colors.transparent,
width: _kImageBoarderWidth,
),
),
margin: EdgeInsets.symmetric(
horizontal: _kImageMarginHorizontal,
vertical: _kImageMarginVertical,
),
padding: EdgeInsets.all(_kImagePaddingWidth),
child: SvgPicture.asset(
'assets/${_kKBLayoutImageMap[kbLayoutType] ?? ""}.svg',
width: imageWidth -
_kImageMarginHorizontal * 2 -
_kImagePaddingWidth * 2 -
_kImageBoarderWidth * 2,
),
);
});
}
}
class _KBChooser extends StatelessWidget {
final String kbLayoutType;
final double imageWidth;
final RxString chosenType;
final KBChosenCallback cb;
const _KBChooser({
Key? key,
required this.kbLayoutType,
required this.imageWidth,
required this.chosenType,
required this.cb,
}) : super(key: key);
@override
Widget build(BuildContext context) {
onChanged(String? v) async {
if (v != null) {
if (await cb(v)) {
chosenType.value = v;
}
}
}
return Column(
children: [
TextButton(
onPressed: () {
onChanged(kbLayoutType);
},
child: _KBImage(
kbLayoutType: kbLayoutType,
imageWidth: imageWidth,
chosenType: chosenType,
),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
),
TextButton(
child: Row(
children: [
Obx(() => Radio(
splashRadius: 0,
value: kbLayoutType,
groupValue: chosenType.value,
onChanged: onChanged,
)),
Text(kbLayoutType),
],
),
onPressed: () {
onChanged(kbLayoutType);
},
),
],
);
}
}
class KBLayoutTypeChooser extends StatelessWidget {
final RxString chosenType;
final double width;
final double height;
final double dividerWidth;
final KBChosenCallback cb;
KBLayoutTypeChooser({
Key? key,
required this.chosenType,
required this.width,
required this.height,
required this.dividerWidth,
required this.cb,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final imageWidth = width / 2 - dividerWidth;
return SizedBox(
width: width,
height: height,
child: Center(
child: Row(
children: [
_KBChooser(
kbLayoutType: _kKBLayoutTypeISO,
imageWidth: imageWidth,
chosenType: chosenType,
cb: cb,
),
VerticalDivider(
width: dividerWidth * 2,
),
_KBChooser(
kbLayoutType: _kKBLayoutTypeNotISO,
imageWidth: imageWidth,
chosenType: chosenType,
cb: cb,
),
],
),
),
);
}
}
RxString KBLayoutType = ''.obs;
String getLocalPlatformForKBLayoutType(String peerPlatform) {
String localPlatform = '';
if (peerPlatform != kPeerPlatformMacOS) {
return localPlatform;
}
if (isWindows) {
localPlatform = kPeerPlatformWindows;
} else if (isLinux) {
localPlatform = kPeerPlatformLinux;
} else if (isWebOnWindows || isWebOnLinux) {
localPlatform = kPeerPlatformWebDesktop;
}
return localPlatform;
}
showKBLayoutTypeChooserIfNeeded(
String peerPlatform,
OverlayDialogManager dialogManager,
) async {
final localPlatform = getLocalPlatformForKBLayoutType(peerPlatform);
if (localPlatform == '') {
return;
}
KBLayoutType.value = bind.getLocalKbLayoutType();
if (KBLayoutType.value == _kKBLayoutTypeISO ||
KBLayoutType.value == _kKBLayoutTypeNotISO) {
return;
}
showKBLayoutTypeChooser(localPlatform, dialogManager);
}
showKBLayoutTypeChooser(
String localPlatform,
OverlayDialogManager dialogManager,
) {
dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title:
Text('${translate('Select local keyboard type')} ($localPlatform)'),
content: KBLayoutTypeChooser(
chosenType: KBLayoutType,
width: 360,
height: 200,
dividerWidth: 4.0,
cb: (String v) async {
await bind.setLocalKbLayoutType(kbLayoutType: v);
KBLayoutType.value = bind.getLocalKbLayoutType();
return v == KBLayoutType.value;
}),
actions: [dialogButton('Close', onPressed: close)],
onCancel: close,
);
});
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
class ListSearchActionListener extends StatelessWidget {
final FocusNode node;
final TimeoutStringBuffer buffer;
final Widget child;
final Function(String) onNext;
final Function(String) onSearch;
const ListSearchActionListener(
{super.key,
required this.node,
required this.buffer,
required this.child,
required this.onNext,
required this.onSearch});
@mustCallSuper
@override
Widget build(BuildContext context) {
return KeyboardListener(
autofocus: true,
onKeyEvent: (kv) {
final ch = kv.character;
if (ch == null) {
return;
}
final action = buffer.input(ch);
switch (action) {
case ListSearchAction.search:
onSearch(buffer.buffer);
break;
case ListSearchAction.next:
onNext(buffer.buffer);
break;
}
},
focusNode: node,
child: child);
}
}
enum ListSearchAction { search, next }
class TimeoutStringBuffer {
var _buffer = "";
late DateTime _duration;
static int timeoutMilliSec = 1500;
String get buffer => _buffer;
TimeoutStringBuffer() {
_duration = DateTime.now();
}
ListSearchAction input(String ch) {
ch = ch.toLowerCase();
final curr = DateTime.now();
try {
if (curr.difference(_duration).inMilliseconds > timeoutMilliSec) {
_buffer = ch;
return ListSearchAction.search;
} else {
if (ch == _buffer) {
return ListSearchAction.next;
} else {
_buffer += ch;
return ListSearchAction.search;
}
}
} finally {
_duration = curr;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
class MenuButton extends StatefulWidget {
final GestureTapCallback? onPressed;
final Color color;
final Color hoverColor;
final Color? splashColor;
final Widget child;
final String? tooltip;
final EdgeInsetsGeometry padding;
final bool enableFeedback;
const MenuButton({
super.key,
required this.onPressed,
required this.color,
required this.hoverColor,
required this.child,
this.splashColor,
this.tooltip = "",
this.padding = const EdgeInsets.symmetric(horizontal: 3, vertical: 6),
this.enableFeedback = true,
});
@override
State<MenuButton> createState() => _MenuButtonState();
}
class _MenuButtonState extends State<MenuButton> {
bool _isHover = false;
final double _borderRadius = 8.0;
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.padding,
child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: widget.tooltip,
child: Material(
type: MaterialType.transparency,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_borderRadius),
color: _isHover ? widget.hoverColor : widget.color,
),
child: InkWell(
hoverColor: widget.hoverColor,
onHover: (val) {
setState(() {
_isHover = val;
});
},
borderRadius: BorderRadius.circular(_borderRadius),
splashColor: widget.splashColor,
enableFeedback: widget.enableFeedback,
onTap: widget.onPressed,
child: widget.child,
),
),
),
),
);
}
}
+755
View File
@@ -0,0 +1,755 @@
import 'dart:core';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import './material_mod_popup_menu.dart' as mod_menu;
// https://stackoverflow.com/questions/68318314/flutter-popup-menu-inside-popup-menu
class PopupMenuChildrenItem<T> extends mod_menu.PopupMenuEntry<T> {
const PopupMenuChildrenItem({
key,
this.height = kMinInteractiveDimension,
this.padding,
this.enabled,
this.textStyle,
this.onTap,
this.position = mod_menu.PopupMenuPosition.overSide,
this.offset = Offset.zero,
required this.itemBuilder,
required this.child,
}) : super(key: key);
final mod_menu.PopupMenuPosition position;
final Offset offset;
final TextStyle? textStyle;
final EdgeInsets? padding;
final RxBool? enabled;
final void Function()? onTap;
final List<mod_menu.PopupMenuEntry<T>> Function(BuildContext) itemBuilder;
final Widget child;
@override
final double height;
@override
bool represents(T? value) => false;
@override
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>> createState() =>
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>>(enabled?.value);
}
class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>>
extends State<W> {
RxBool enabled = true.obs;
MyPopupMenuItemState(bool? e) {
if (e != null) {
enabled.value = e;
}
}
@protected
void handleTap(T value) {
widget.onTap?.call();
Navigator.pop<T>(context, value);
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
TextStyle style = widget.textStyle ??
popupMenuTheme.textStyle ??
theme.textTheme.titleMedium!;
return Obx(() => mod_menu.PopupMenuButton<T>(
enabled: enabled.value,
position: widget.position,
offset: widget.offset,
onSelected: handleTap,
itemBuilder: widget.itemBuilder,
padding: EdgeInsets.zero,
child: AnimatedDefaultTextStyle(
style: style,
duration: kThemeChangeDuration,
child: Container(
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints(
minHeight: widget.height, maxHeight: widget.height),
padding:
widget.padding ?? const EdgeInsets.symmetric(horizontal: 16),
child: widget.child,
),
),
));
}
}
class MenuConfig {
// adapt to the screen height
static const fontSize = 14.0;
static const midPadding = 10.0;
static const iconScale = 0.8;
static const iconWidth = 12.0;
static const iconHeight = 12.0;
final double height;
final double dividerHeight;
final double? boxWidth;
final Color commonColor;
const MenuConfig(
{required this.commonColor,
this.height = kMinInteractiveDimension,
this.dividerHeight = 16.0,
this.boxWidth});
}
typedef DismissCallback = Function();
abstract class MenuEntryBase<T> {
bool dismissOnClicked;
DismissCallback? dismissCallback;
RxBool? enabled;
MenuEntryBase({
this.dismissOnClicked = false,
this.enabled,
this.dismissCallback,
});
List<mod_menu.PopupMenuEntry<T>> build(BuildContext context, MenuConfig conf);
enabledStyle(BuildContext context) => TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal);
disabledStyle() => TextStyle(
color: Colors.grey,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal);
}
class MenuEntryDivider<T> extends MenuEntryBase<T> {
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return [
mod_menu.PopupMenuDivider(
height: conf.dividerHeight,
)
];
}
}
class MenuEntryRadioOption {
String text;
String value;
bool dismissOnClicked;
RxBool? enabled;
DismissCallback? dismissCallback;
MenuEntryRadioOption({
required this.text,
required this.value,
this.dismissOnClicked = false,
this.enabled,
this.dismissCallback,
});
}
typedef RadioOptionsGetter = List<MenuEntryRadioOption> Function();
typedef RadioCurOptionGetter = Future<String> Function();
typedef RadioOptionSetter = Future<void> Function(
String oldValue, String newValue);
class MenuEntryRadioUtils<T> {}
class MenuEntryRadios<T> extends MenuEntryBase<T> {
final String text;
final RadioOptionsGetter optionsGetter;
final RadioCurOptionGetter curOptionGetter;
final RadioOptionSetter optionSetter;
final RxString _curOption = "".obs;
final EdgeInsets? padding;
MenuEntryRadios({
required this.text,
required this.optionsGetter,
required this.curOptionGetter,
required this.optionSetter,
this.padding,
dismissOnClicked = false,
dismissCallback,
RxBool? enabled,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
) {
() async {
_curOption.value = await curOptionGetter();
}();
}
List<MenuEntryRadioOption> get options => optionsGetter();
RxString get curOption => _curOption;
setOption(String option) async {
await optionSetter(_curOption.value, option);
if (_curOption.value != option) {
final opt = await curOptionGetter();
if (_curOption.value != opt) {
_curOption.value = opt;
}
}
}
mod_menu.PopupMenuEntry<T> _buildMenuItem(
BuildContext context, MenuConfig conf, MenuEntryRadioOption opt) {
Widget getTextChild() {
final enabledTextChild = Text(
opt.text,
style: enabledStyle(context),
);
final disabledTextChild = Text(
opt.text,
style: disabledStyle(),
);
if (opt.enabled == null) {
return enabledTextChild;
} else {
return Obx(
() => opt.enabled!.isTrue ? enabledTextChild : disabledTextChild);
}
}
final child = Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
constraints:
BoxConstraints(minHeight: conf.height, maxHeight: conf.height),
child: Row(
children: [
getTextChild(),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() => opt.value == curOption.value
? IconButton(
padding:
const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 0.0),
hoverColor: Colors.transparent,
focusColor: Colors.transparent,
onPressed: () {},
icon: Icon(
Icons.check,
color: (opt.enabled ?? true.obs).isTrue
? conf.commonColor
: Colors.grey,
))
: const SizedBox.shrink()),
))),
],
),
);
onPressed() {
if (opt.dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
if (opt.dismissCallback != null) {
opt.dismissCallback!();
}
}
setOption(opt.value);
}
return mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: Container(
width: conf.boxWidth,
child: opt.enabled == null
? TextButton(
child: child,
onPressed: onPressed,
)
: Obx(() => TextButton(
child: child,
onPressed: opt.enabled!.isTrue ? onPressed : null,
)),
),
);
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return options.map((opt) => _buildMenuItem(context, conf, opt)).toList();
}
}
class MenuEntrySubRadios<T> extends MenuEntryBase<T> {
final String text;
final RadioOptionsGetter optionsGetter;
final RadioCurOptionGetter curOptionGetter;
final RadioOptionSetter optionSetter;
final RxString _curOption = "".obs;
final EdgeInsets? padding;
MenuEntrySubRadios({
required this.text,
required this.optionsGetter,
required this.curOptionGetter,
required this.optionSetter,
this.padding,
dismissOnClicked = false,
RxBool? enabled,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
) {
() async {
_curOption.value = await curOptionGetter();
}();
}
List<MenuEntryRadioOption> get options => optionsGetter();
RxString get curOption => _curOption;
setOption(String option) async {
await optionSetter(_curOption.value, option);
if (_curOption.value != option) {
final opt = await curOptionGetter();
if (_curOption.value != opt) {
_curOption.value = opt;
}
}
}
mod_menu.PopupMenuEntry<T> _buildSecondMenu(
BuildContext context, MenuConfig conf, MenuEntryRadioOption opt) {
return mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: Container(
width: conf.boxWidth,
child: TextButton(
child: Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints(
minHeight: conf.height, maxHeight: conf.height),
child: Row(
children: [
Text(
opt.text,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() => opt.value == curOption.value
? IconButton(
padding: EdgeInsets.zero,
hoverColor: Colors.transparent,
focusColor: Colors.transparent,
onPressed: () {},
icon: Icon(
Icons.check,
color: conf.commonColor,
))
: const SizedBox.shrink())),
)),
],
),
),
onPressed: () {
if (opt.dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
if (opt.dismissCallback != null) {
opt.dismissCallback!();
}
}
setOption(opt.value);
},
)),
);
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return [
PopupMenuChildrenItem(
enabled: super.enabled,
padding: padding,
height: conf.height,
itemBuilder: (BuildContext context) =>
options.map((opt) => _buildSecondMenu(context, conf, opt)).toList(),
child: Row(children: [
const SizedBox(width: MenuConfig.midPadding),
Text(
text,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Icon(
Icons.keyboard_arrow_right,
color: conf.commonColor,
),
))
]),
)
];
}
}
enum SwitchType {
sswitch,
scheckbox,
}
typedef SwitchGetter = Future<bool> Function();
typedef SwitchSetter = Future<void> Function(bool);
abstract class MenuEntrySwitchBase<T> extends MenuEntryBase<T> {
final SwitchType switchType;
final String text;
final EdgeInsets? padding;
Rx<TextStyle>? textStyle;
MenuEntrySwitchBase({
required this.switchType,
required this.text,
required dismissOnClicked,
this.textStyle,
this.padding,
RxBool? enabled,
dismissCallback,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
);
bool get isEnabled => enabled?.value ?? true;
RxBool get curOption;
Future<void> setOption(bool? option);
tryPop(BuildContext context) {
if (dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
super.dismissCallback?.call();
}
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
textStyle ??= TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal)
.obs;
return [
mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: Container(
width: conf.boxWidth,
child: TextButton(
child: Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
height: conf.height,
child: Row(children: [
Obx(() => Text(
text,
style: textStyle!.value,
)),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() {
if (switchType == SwitchType.sswitch) {
return Switch(
value: curOption.value,
onChanged: isEnabled
? (v) {
tryPop(context);
setOption(v);
}
: null,
);
} else {
return Checkbox(
value: curOption.value,
onChanged: isEnabled
? (v) {
tryPop(context);
setOption(v);
}
: null,
);
}
})),
))
])),
onPressed: isEnabled
? () {
tryPop(context);
setOption(!curOption.value);
}
: null,
)),
)
];
}
}
class MenuEntrySwitch<T> extends MenuEntrySwitchBase<T> {
final SwitchGetter getter;
final SwitchSetter setter;
final RxBool _curOption = false.obs;
MenuEntrySwitch({
required SwitchType switchType,
required String text,
required this.getter,
required this.setter,
Rx<TextStyle>? textStyle,
EdgeInsets? padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
switchType: switchType,
text: text,
textStyle: textStyle,
padding: padding,
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
) {
() async {
_curOption.value = await getter();
}();
}
@override
RxBool get curOption => _curOption;
@override
setOption(bool? option) async {
if (option != null) {
await setter(option);
final opt = await getter();
if (_curOption.value != opt) {
_curOption.value = opt;
}
}
}
}
// Compatible with MenuEntrySwitch, it uses value instead of getter
class MenuEntrySwitchSync<T> extends MenuEntrySwitchBase<T> {
final SwitchSetter setter;
final RxBool _curOption = false.obs;
MenuEntrySwitchSync({
required SwitchType switchType,
required String text,
required bool currentValue,
required this.setter,
Rx<TextStyle>? textStyle,
EdgeInsets? padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
switchType: switchType,
text: text,
textStyle: textStyle,
padding: padding,
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
) {
_curOption.value = currentValue;
}
@override
RxBool get curOption => _curOption;
@override
setOption(bool? option) async {
if (option != null) {
await setter(option);
// Notice: no ensure with getter, best used on menus that are destroyed on click
if (_curOption.value != option) {
_curOption.value = option;
}
}
}
}
typedef Switch2Getter = RxBool Function();
typedef Switch2Setter = Future<void> Function(bool);
class MenuEntrySwitch2<T> extends MenuEntrySwitchBase<T> {
final Switch2Getter getter;
final SwitchSetter setter;
MenuEntrySwitch2({
required SwitchType switchType,
required String text,
required this.getter,
required this.setter,
Rx<TextStyle>? textStyle,
EdgeInsets? padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
switchType: switchType,
text: text,
textStyle: textStyle,
padding: padding,
dismissOnClicked: dismissOnClicked,
dismissCallback: dismissCallback,
);
@override
RxBool get curOption => getter();
@override
setOption(bool? option) async {
if (option != null) {
await setter(option);
}
}
}
class MenuEntrySubMenu<T> extends MenuEntryBase<T> {
final String text;
final List<MenuEntryBase<T>> entries;
final EdgeInsets? padding;
MenuEntrySubMenu({
required this.text,
required this.entries,
this.padding,
RxBool? enabled,
}) : super(enabled: enabled);
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
super.enabled ??= true.obs;
return [
PopupMenuChildrenItem(
enabled: super.enabled,
height: conf.height,
padding: padding,
position: mod_menu.PopupMenuPosition.overSide,
itemBuilder: (BuildContext context) => entries
.map((entry) => entry.build(context, conf))
.expand((i) => i)
.toList(),
child: Row(children: [
const SizedBox(width: MenuConfig.midPadding),
Obx(() => Text(
text,
style: super.enabled!.value
? enabledStyle(context)
: disabledStyle(),
)),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Obx(() => Icon(
Icons.keyboard_arrow_right,
color: super.enabled!.value ? conf.commonColor : Colors.grey,
)),
))
]),
)
];
}
}
class MenuEntryButton<T> extends MenuEntryBase<T> {
final Widget Function(TextStyle? style) childBuilder;
Function() proc;
final EdgeInsets? padding;
MenuEntryButton({
required this.childBuilder,
required this.proc,
this.padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
);
Widget _buildChild(BuildContext context, MenuConfig conf) {
super.enabled ??= true.obs;
return Obx(() => Container(
width: conf.boxWidth,
child: TextButton(
onPressed: super.enabled!.value
? () {
if (super.dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
if (super.dismissCallback != null) {
super.dismissCallback!();
}
}
proc();
}
: null,
child: Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
constraints:
BoxConstraints(minHeight: conf.height, maxHeight: conf.height),
child: childBuilder(
super.enabled!.value ? enabledStyle(context) : disabledStyle()),
),
)));
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return [
mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: _buildChild(context, conf),
)
];
}
}
class CustomPopupMenuTheme {
static const Color commonColor = MyTheme.accent;
// kMinInteractiveDimension
static const double height = 20.0;
static const double dividerHeight = 3.0;
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/main.dart';
import 'package:get/get.dart';
class RefreshWrapper extends StatefulWidget {
final Widget Function(BuildContext context) builder;
const RefreshWrapper({super.key, required this.builder});
@override
State<RefreshWrapper> createState() => RefreshWrapperState();
static RefreshWrapperState? of(BuildContext context) {
final state = context.findAncestorStateOfType<RefreshWrapperState>();
if (state == null) {
debugPrint(
"RefreshWrapperState not exists in this context, perhaps RefreshWrapper is not exists?");
}
return state;
}
}
class RefreshWrapperState extends State<RefreshWrapper> {
@override
Widget build(BuildContext context) {
return widget.builder(context);
}
rebuild() {
debugPrint("=====Global State Rebuild (win-${kWindowId ?? 'main'})=====");
if (Get.context != null) {
(context as Element).visitChildren(_rebuildElement);
}
// Synchronize the window theme of the system.
updateSystemWindowTheme();
setState(() {});
}
/// set root tree dirty to trigger global rebuild
void _rebuildElement(Element el) {
el.markNeedsBuild();
el.visitChildren(_rebuildElement);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
const sidebarColor = Color(0xFF0C6AF6);
const backgroundStartColor = Color(0xFF0583EA);
const backgroundEndColor = Color(0xFF0697EA);
class DesktopTitleBar extends StatelessWidget {
final Widget? child;
const DesktopTitleBar({Key? key, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [backgroundStartColor, backgroundEndColor],
stops: [0.0, 1.0]),
),
child: Row(
children: [
Expanded(
child: child ?? Offstage(),
)
],
),
);
}
}
@@ -0,0 +1,267 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
final _isExtracting = false.obs;
void handleUpdate(String releasePageUrl) {
_isExtracting.value = false;
String downloadUrl = releasePageUrl.replaceAll('tag', 'download');
String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
final String downloadFile =
bind.mainGetCommonSync(key: 'download-file-$version');
if (downloadFile.startsWith('error:')) {
final error = downloadFile.replaceFirst('error:', '');
msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error,
releasePageUrl, gFFI.dialogManager);
return;
}
downloadUrl = '$downloadUrl/$downloadFile';
SimpleWrapper downloadId = SimpleWrapper('');
SimpleWrapper<VoidCallback> onCanceled = SimpleWrapper(() {});
gFFI.dialogManager.dismissAll();
gFFI.dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Obx(() => Text(translate(_isExtracting.isTrue
? 'Preparing for installation ...'
: 'Downloading {$appName}'))),
content:
UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled)
.marginSymmetric(horizontal: 8)
.paddingOnly(top: 12),
actions: [
if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async {
onCanceled.value();
await bind.mainSetCommon(
key: 'cancel-downloader', value: downloadId.value);
// Wait for the downloader to be removed.
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 300));
final isCanceled = 'error:Downloader not found' ==
await bind.mainGetCommon(
key: 'download-data-${downloadId.value}');
if (isCanceled) {
break;
}
}
close();
}, isOutline: true),
]);
});
}
class UpdateProgress extends StatefulWidget {
final String releasePageUrl;
final String downloadUrl;
final SimpleWrapper downloadId;
final SimpleWrapper onCanceled;
UpdateProgress(
this.releasePageUrl, this.downloadUrl, this.downloadId, this.onCanceled,
{Key? key})
: super(key: key);
@override
State<UpdateProgress> createState() => UpdateProgressState();
}
class UpdateProgressState extends State<UpdateProgress> {
Timer? _timer;
int? _totalSize;
int _downloadedSize = 0;
int _getDataFailedCount = 0;
final String _eventKeyDownloadNewVersion = 'download-new-version';
final String _eventKeyExtractUpdateDmg = 'extract-update-dmg';
@override
void initState() {
super.initState();
widget.onCanceled.value = () {
cancelQueryTimer();
};
platformFFI.registerEventHandler(_eventKeyDownloadNewVersion,
_eventKeyDownloadNewVersion, handleDownloadNewVersion,
replace: true);
bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl);
if (isMacOS) {
platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg,
_eventKeyExtractUpdateDmg, handleExtractUpdateDmg,
replace: true);
}
}
@override
void dispose() {
cancelQueryTimer();
platformFFI.unregisterEventHandler(
_eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion);
if (isMacOS) {
platformFFI.unregisterEventHandler(
_eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg);
}
super.dispose();
}
void cancelQueryTimer() {
_timer?.cancel();
_timer = null;
}
Future<void> handleDownloadNewVersion(Map<String, dynamic> evt) async {
if (evt.containsKey('id')) {
widget.downloadId.value = evt['id'] as String;
_timer = Timer.periodic(const Duration(milliseconds: 300), (timer) {
_updateDownloadData();
});
} else {
if (evt.containsKey('error')) {
_onError(evt['error'] as String);
} else {
// unreachable
_onError('$evt');
}
}
}
// `isExtractDmg` is true when handling extract-update-dmg event.
// It's a rare case that the dmg file is corrupted and cannot be extracted.
void _onError(String error, {bool isExtractDmg = false}) {
cancelQueryTimer();
debugPrint(
'${isExtractDmg ? "Extract" : "Download"} new version error: $error');
final msgBoxType = 'custom-nocancel-nook-hasclose';
final msgBoxTitle = 'Error';
final msgBoxText = 'download-new-version-failed-tip';
final dialogManager = gFFI.dialogManager;
close() {
dialogManager.dismissAll();
}
jumplink() {
launchUrl(Uri.parse(widget.releasePageUrl));
dialogManager.dismissAll();
}
retry() {
dialogManager.dismissAll();
handleUpdate(widget.releasePageUrl);
}
final List<Widget> buttons = [
dialogButton('Download', onPressed: jumplink),
if (!isExtractDmg) dialogButton('Retry', onPressed: retry),
dialogButton('Close', onPressed: close),
];
dialogManager.dismissAll();
dialogManager.show(
(setState, close, context) => CustomAlertDialog(
title: null,
content: SelectionArea(
child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)),
actions: buttons,
),
tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle',
);
}
void _updateDownloadData() {
String err = '';
String downloadData =
bind.mainGetCommonSync(key: 'download-data-${widget.downloadId.value}');
if (downloadData.startsWith('error:')) {
err = downloadData.substring('error:'.length);
} else {
try {
jsonDecode(downloadData).forEach((key, value) {
if (key == 'total_size') {
if (value != null && value is int) {
_totalSize = value;
}
} else if (key == 'downloaded_size') {
_downloadedSize = value as int;
} else if (key == 'error') {
if (value != null) {
err = value.toString();
}
}
});
} catch (e) {
_getDataFailedCount += 1;
debugPrint(
'Failed to get download data ${widget.downloadUrl}, error $e');
if (_getDataFailedCount > 3) {
err = e.toString();
}
}
}
if (err != '') {
_onError(err);
} else {
if (_totalSize != null && _downloadedSize >= _totalSize!) {
cancelQueryTimer();
bind.mainSetCommon(
key: 'remove-downloader', value: widget.downloadId.value);
if (_totalSize == 0) {
_onError('The download file size is 0.');
} else {
setState(() {});
if (isMacOS) {
bind.mainSetCommon(
key: 'extract-update-dmg', value: widget.downloadUrl);
_isExtracting.value = true;
} else {
updateMsgBox();
}
}
} else {
setState(() {});
}
}
}
void updateMsgBox() {
msgBox(
gFFI.sessionId,
'custom-nocancel',
'{$appName} Update',
'{$appName}-to-update-tip',
'',
gFFI.dialogManager,
onSubmit: () {
debugPrint('Downloaded, update to new version now');
bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl);
},
submitTimeout: 5,
);
}
Future<void> handleExtractUpdateDmg(Map<String, dynamic> evt) async {
_isExtracting.value = false;
if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) {
_onError(evt['err'] as String, isExtractDmg: true);
} else {
updateMsgBox();
}
}
@override
Widget build(BuildContext context) {
getValue() => _totalSize == null
? 0.0
: (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!);
return LinearProgressIndicator(
value: _isExtracting.isTrue ? null : getValue(),
minHeight: 20,
borderRadius: BorderRadius.circular(5),
backgroundColor: Colors.grey[300],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
);
}
}
+603
View File
@@ -0,0 +1,603 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:bot_toast/bot_toast.dart';
import 'package:desktop_multi_window/desktop_multi_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/widgets/overlay.dart';
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
import 'package:flutter_hbb/desktop/pages/install_page.dart';
import 'package:flutter_hbb/desktop/pages/server_page.dart';
import 'package:flutter_hbb/desktop/screen/desktop_file_transfer_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_view_camera_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_port_forward_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_remote_screen.dart';
import 'package:flutter_hbb/desktop/screen/desktop_terminal_screen.dart';
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:window_manager/window_manager.dart';
import 'common.dart';
import 'consts.dart';
import 'mobile/pages/home_page.dart';
import 'mobile/pages/server_page.dart';
import 'mobile/widgets/deploy_dialog.dart';
import 'models/platform_model.dart';
import 'package:flutter_hbb/plugin/handlers.dart'
if (dart.library.html) 'package:flutter_hbb/web/plugin/handlers.dart';
/// Basic window and launch properties.
int? kWindowId;
WindowType? kWindowType;
late List<String> kBootArgs;
Future<void> main(List<String> args) async {
earlyAssert();
WidgetsFlutterBinding.ensureInitialized();
debugPrint("launch args: $args");
kBootArgs = List.from(args);
if (!isDesktop) {
runMobileApp();
return;
}
// main window
if (args.isNotEmpty && args.first == 'multi_window') {
kWindowId = int.parse(args[1]);
stateGlobal.setWindowId(kWindowId!);
if (!isMacOS) {
WindowController.fromWindowId(kWindowId!).showTitleBar(false);
}
final argument = args[2].isEmpty
? <String, dynamic>{}
: jsonDecode(args[2]) as Map<String, dynamic>;
int type = argument['type'] ?? -1;
// to-do: No need to parse window id ?
// Because stateGlobal.windowId is a global value.
argument['windowId'] = kWindowId;
kWindowType = type.windowType;
switch (kWindowType) {
case WindowType.RemoteDesktop:
desktopType = DesktopType.remote;
runMultiWindow(
argument,
kAppTypeDesktopRemote,
);
break;
case WindowType.FileTransfer:
desktopType = DesktopType.fileTransfer;
runMultiWindow(
argument,
kAppTypeDesktopFileTransfer,
);
break;
case WindowType.ViewCamera:
desktopType = DesktopType.viewCamera;
runMultiWindow(
argument,
kAppTypeDesktopViewCamera,
);
break;
case WindowType.PortForward:
desktopType = DesktopType.portForward;
runMultiWindow(
argument,
kAppTypeDesktopPortForward,
);
break;
case WindowType.Terminal:
desktopType = DesktopType.terminal;
runMultiWindow(
argument,
kAppTypeDesktopTerminal,
);
default:
break;
}
} else if (args.isNotEmpty && args.first == '--cm') {
debugPrint("--cm started");
desktopType = DesktopType.cm;
await windowManager.ensureInitialized();
runConnectionManagerScreen();
} else if (args.contains('--install')) {
runInstallPage();
} else {
desktopType = DesktopType.main;
await windowManager.ensureInitialized();
windowManager.setPreventClose(true);
if (isMacOS) {
disableWindowMovable(kWindowId);
}
runMainApp(true);
}
}
Future<void> initEnv(String appType) async {
// global shared preference
await platformFFI.init(appType);
// global FFI, use this **ONLY** for global configuration
// for convenience, use global FFI on mobile platform
// focus on multi-ffi on desktop first
await initGlobalFFI();
// await Firebase.initializeApp();
_registerEventHandler();
// Update the system theme.
updateSystemWindowTheme();
}
void runMainApp(bool startService) async {
// register uni links
await initEnv(kAppTypeMain);
checkUpdate();
// trigger connection status updater
await bind.mainCheckConnectStatus();
if (startService) {
gFFI.serverModel.startService();
bind.pluginSyncUi(syncTo: kAppTypeMain);
bind.pluginListReload();
}
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
gFFI.userModel.refreshCurrentUser();
runApp(App());
bool? alwaysOnTop;
if (isDesktop) {
alwaysOnTop =
bind.mainGetBuildinOption(key: "main-window-always-on-top") == 'Y';
}
// Set window option.
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
isMainWindow: true, alwaysOnTop: alwaysOnTop);
windowManager.waitUntilReadyToShow(windowOptions, () async {
// Restore the location of the main window before window hide or show.
await restoreWindowPosition(WindowType.Main);
// Check the startup argument, if we successfully handle the argument, we keep the main window hidden.
final handledByUniLinks = await initUniLinks();
debugPrint("handled by uni links: $handledByUniLinks");
if (handledByUniLinks || handleUriLink(cmdArgs: kBootArgs)) {
windowManager.hide();
} else {
windowManager.show();
windowManager.focus();
// Move registration of active main window here to prevent from async visible check.
rustDeskWinManager.registerActiveWindow(kWindowMainId);
}
windowManager.setOpacity(1);
windowManager.setTitle(getWindowName());
// Do not use `windowManager.setResizable()` here.
setResizable(!bind.isIncomingOnly());
});
}
void runMobileApp() async {
await initEnv(kAppTypeMain);
checkUpdate();
if (isAndroid) androidChannelInit();
if (isAndroid) platformFFI.syncAndroidServiceAppDirConfigPath();
draggablePositions.load();
await Future.wait([gFFI.abModel.loadCache(), gFFI.groupModel.loadCache()]);
gFFI.userModel.refreshCurrentUser();
runApp(App());
await initUniLinks();
}
void runMultiWindow(
Map<String, dynamic> argument,
String appType,
) async {
await initEnv(appType);
final title = getWindowName();
// set prevent close to true, we handle close event manually
WindowController.fromWindowId(kWindowId!).setPreventClose(true);
if (isMacOS) {
disableWindowMovable(kWindowId);
}
late Widget widget;
switch (appType) {
case kAppTypeDesktopRemote:
draggablePositions.load();
widget = DesktopRemoteScreen(
params: argument,
);
break;
case kAppTypeDesktopFileTransfer:
widget = DesktopFileTransferScreen(
params: argument,
);
break;
case kAppTypeDesktopViewCamera:
draggablePositions.load();
widget = DesktopViewCameraScreen(
params: argument,
);
break;
case kAppTypeDesktopPortForward:
widget = DesktopPortForwardScreen(
params: argument,
);
break;
case kAppTypeDesktopTerminal:
widget = DesktopTerminalScreen(
params: argument,
);
break;
default:
// no such appType
exit(0);
}
_runApp(
title,
widget,
MyTheme.currentThemeMode(),
);
// we do not hide titlebar on win7 because of the frame overflow.
if (kUseCompatibleUiMode) {
WindowController.fromWindowId(kWindowId!).showTitleBar(true);
}
switch (appType) {
case kAppTypeDesktopRemote:
// If screen rect is set, the window will be moved to the target screen and then set fullscreen.
if (argument['screen_rect'] == null) {
// display can be used to control the offset of the window.
await restoreWindowPosition(
WindowType.RemoteDesktop,
windowId: kWindowId!,
peerId: argument['id'] as String?,
display: argument['display'] as int?,
);
}
break;
case kAppTypeDesktopFileTransfer:
await restoreWindowPosition(WindowType.FileTransfer,
windowId: kWindowId!);
break;
case kAppTypeDesktopViewCamera:
// If screen rect is set, the window will be moved to the target screen and then set fullscreen.
if (argument['screen_rect'] == null) {
// display can be used to control the offset of the window.
await restoreWindowPosition(
WindowType.ViewCamera,
windowId: kWindowId!,
peerId: argument['id'] as String?,
// FIXME: fix display index.
display: argument['display'] as int?,
);
}
break;
case kAppTypeDesktopPortForward:
await restoreWindowPosition(WindowType.PortForward, windowId: kWindowId!);
break;
case kAppTypeDesktopTerminal:
await restoreWindowPosition(WindowType.Terminal, windowId: kWindowId!);
break;
default:
// no such appType
exit(0);
}
// show window from hidden status
WindowController.fromWindowId(kWindowId!).show();
}
void runConnectionManagerScreen() async {
await initEnv(kAppTypeConnectionManager);
_runApp(
'',
const DesktopServerPage(),
MyTheme.currentThemeMode(),
);
final hide = await bind.cmGetConfig(name: "hide_cm") == 'true';
gFFI.serverModel.hideCm = hide;
if (hide) {
await hideCmWindow(isStartup: true);
} else {
await showCmWindow(isStartup: true);
}
setResizable(false);
// Start the uni links handler and redirect links to Native, not for Flutter.
listenUniLinks(handleByFlutter: false);
}
bool _isCmReadyToShow = false;
showCmWindow({bool isStartup = false}) async {
if (isStartup) {
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat, alwaysOnTop: true);
await windowManager.waitUntilReadyToShow(windowOptions, null);
bind.mainHideDock();
await Future.wait([
windowManager.show(),
windowManager.focus(),
windowManager.setOpacity(1)
]);
// ensure initial window size to be changed
await windowManager.setSizeAlignment(
kConnectionManagerWindowSizeClosedChat, Alignment.topRight);
_isCmReadyToShow = true;
} else if (_isCmReadyToShow) {
if (await windowManager.getOpacity() != 1) {
await windowManager.setOpacity(1);
await windowManager.focus();
await windowManager.minimize(); //needed
await windowManager.setSizeAlignment(
kConnectionManagerWindowSizeClosedChat, Alignment.topRight);
windowOnTop(null);
}
}
}
hideCmWindow({bool isStartup = false}) async {
if (isStartup) {
WindowOptions windowOptions = getHiddenTitleBarWindowOptions(
size: kConnectionManagerWindowSizeClosedChat);
windowManager.setOpacity(0);
await windowManager.waitUntilReadyToShow(windowOptions, null);
bind.mainHideDock();
await windowManager.minimize();
await windowManager.hide();
_isCmReadyToShow = true;
} else if (_isCmReadyToShow) {
if (await windowManager.getOpacity() != 0) {
await windowManager.setOpacity(0);
bind.mainHideDock();
await windowManager.minimize();
await windowManager.hide();
}
}
}
void _runApp(
String title,
Widget home,
ThemeMode themeMode,
) {
final botToastBuilder = BotToastInit();
runApp(RefreshWrapper(
builder: (context) => GetMaterialApp(
navigatorKey: globalKey,
debugShowCheckedModeBanner: false,
title: title,
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: themeMode,
home: home,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: supportedLocales,
navigatorObservers: [
// FirebaseAnalyticsObserver(analytics: analytics),
BotToastNavigatorObserver(),
],
builder: (context, child) {
child = _keepScaleBuilder(context, child);
child = botToastBuilder(context, child);
return child;
},
),
));
}
void runInstallPage() async {
await windowManager.ensureInitialized();
await initEnv(kAppTypeMain);
_runApp('', const InstallPage(), MyTheme.currentThemeMode());
WindowOptions windowOptions =
getHiddenTitleBarWindowOptions(size: Size(800, 600), center: true);
windowManager.waitUntilReadyToShow(windowOptions, () async {
windowManager.show();
windowManager.focus();
windowManager.setOpacity(1);
windowManager.setAlignment(Alignment.center); // ensure
});
}
WindowOptions getHiddenTitleBarWindowOptions(
{bool isMainWindow = false,
Size? size,
bool center = false,
bool? alwaysOnTop}) {
var defaultTitleBarStyle = TitleBarStyle.hidden;
// we do not hide titlebar on win7 because of the frame overflow.
if (kUseCompatibleUiMode) {
defaultTitleBarStyle = TitleBarStyle.normal;
}
return WindowOptions(
size: size,
center: center,
backgroundColor: (isMacOS && isMainWindow) ? null : Colors.transparent,
skipTaskbar: false,
titleBarStyle: defaultTitleBarStyle,
alwaysOnTop: alwaysOnTop,
);
}
class App extends StatefulWidget {
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.window.onPlatformBrightnessChanged = () {
final userPreference = MyTheme.getThemeModePreference();
if (userPreference != ThemeMode.system) return;
WidgetsBinding.instance.handlePlatformBrightnessChanged();
final systemIsDark =
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
Brightness.dark;
final ThemeMode to;
if (systemIsDark) {
to = ThemeMode.dark;
} else {
to = ThemeMode.light;
}
Get.changeThemeMode(to);
// Synchronize the window theme of the system.
updateSystemWindowTheme();
if (desktopType == DesktopType.main) {
bind.mainChangeTheme(dark: to.toShortString());
}
};
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) => _updateOrientation());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
_updateOrientation();
}
void _updateOrientation() {
if (isDesktop) return;
// Don't use `MediaQuery.of(context).orientation` in `didChangeMetrics()`,
// my test (Flutter 3.19.6, Android 14) is always the reverse value.
// https://github.com/flutter/flutter/issues/60899
// stateGlobal.isPortrait.value =
// MediaQuery.of(context).orientation == Orientation.portrait;
final orientation = View.of(context).physicalSize.aspectRatio > 1
? Orientation.landscape
: Orientation.portrait;
stateGlobal.isPortrait.value = orientation == Orientation.portrait;
}
@override
Widget build(BuildContext context) {
// final analytics = FirebaseAnalytics.instance;
final botToastBuilder = BotToastInit();
return RefreshWrapper(builder: (context) {
return MultiProvider(
providers: [
// global configuration
// use session related FFI when in remote control or file transfer page
ChangeNotifierProvider.value(value: gFFI.ffiModel),
ChangeNotifierProvider.value(value: gFFI.imageModel),
ChangeNotifierProvider.value(value: gFFI.cursorModel),
ChangeNotifierProvider.value(value: gFFI.canvasModel),
ChangeNotifierProvider.value(value: gFFI.peerTabModel),
],
child: GetMaterialApp(
navigatorKey: globalKey,
debugShowCheckedModeBanner: false,
title: isWeb
? '${bind.mainGetAppNameSync()} Web Client V2 (Preview)'
: bind.mainGetAppNameSync(),
theme: MyTheme.lightTheme,
darkTheme: MyTheme.darkTheme,
themeMode: MyTheme.currentThemeMode(),
home: isDesktop
? const DesktopTabPage()
: isWeb
? WebHomePage()
: HomePage(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: supportedLocales,
navigatorObservers: [
// FirebaseAnalyticsObserver(analytics: analytics),
BotToastNavigatorObserver(),
],
builder: isAndroid
? (context, child) => AccessibilityListener(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
),
child: child ?? Container(),
),
)
: (context, child) {
child = _keepScaleBuilder(context, child);
child = botToastBuilder(context, child);
if ((isDesktop && desktopType == DesktopType.main) ||
isWebDesktop) {
child = keyListenerBuilder(context, child);
}
if (isLinux) {
return buildVirtualWindowFrame(context, child);
} else {
return workaroundWindowBorder(context, child);
}
},
),
);
});
}
}
Widget _keepScaleBuilder(BuildContext context, Widget? child) {
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(1.0),
),
child: child ?? Container(),
);
}
_registerEventHandler() {
if (isDesktop && desktopType != DesktopType.main) {
platformFFI.registerEventHandler('theme', 'theme', (evt) async {
String? dark = evt['dark'];
if (dark != null) {
await MyTheme.changeDarkMode(MyTheme.themeModeFromString(dark));
}
});
platformFFI.registerEventHandler('language', 'language', (_) async {
reloadAllWindows();
});
}
// Register native handlers.
if (isDesktop) {
platformFFI.registerEventHandler('native_ui', 'native_ui', (evt) async {
NativeUiHandler.instance.onEvent(evt);
});
}
if (isAndroid) {
platformFFI.registerEventHandler(
'android_needs_deploy', 'android_needs_deploy', (_) async {
WidgetsBinding.instance.addPostFrameCallback((_) {
showDeployPromptDialog();
});
});
}
}
Widget keyListenerBuilder(BuildContext context, Widget? child) {
return RawKeyboardListener(
focusNode: FocusNode(),
child: child ?? Container(),
onKey: (RawKeyEvent event) {
if (event.logicalKey == LogicalKeyboardKey.shiftLeft) {
if (event is RawKeyDownEvent) {
gFFI.peerTabModel.setShiftDown(true);
} else if (event is RawKeyUpEvent) {
gFFI.peerTabModel.setShiftDown(false);
}
}
},
);
}
@@ -0,0 +1,377 @@
import 'dart:async';
import 'package:auto_size_text_field/auto_size_text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
import 'package:flutter_hbb/common/widgets/connection_page_title.dart';
import 'package:flutter_hbb/models/state_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_hbb/models/peer_model.dart';
import '../../common.dart';
import '../../common/widgets/peer_tab_page.dart';
import '../../common/widgets/autocomplete.dart';
import '../../consts.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import 'home_page.dart';
/// Connection page for connecting to a remote peer.
class ConnectionPage extends StatefulWidget implements PageShape {
ConnectionPage({Key? key, required this.appBarActions}) : super(key: key);
@override
final icon = const Icon(Icons.connected_tv);
@override
final title = translate("Connection");
@override
final List<Widget> appBarActions;
@override
State<ConnectionPage> createState() => _ConnectionPageState();
}
/// State for the connection page.
class _ConnectionPageState extends State<ConnectionPage> {
/// Controller for the id input bar.
final _idController = IDTextEditingController();
final RxBool _idEmpty = true.obs;
final FocusNode _idFocusNode = FocusNode();
final TextEditingController _idEditingController = TextEditingController();
final AllPeersLoader _allPeersLoader = AllPeersLoader();
StreamSubscription? _uniLinksSubscription;
// https://github.com/flutter/flutter/issues/157244
Iterable<Peer> _autocompleteOpts = [];
_ConnectionPageState() {
if (!isWeb) _uniLinksSubscription = listenUniLinks();
_idController.addListener(() {
_idEmpty.value = _idController.text.isEmpty;
});
Get.put<IDTextEditingController>(_idController);
}
@override
void initState() {
super.initState();
_allPeersLoader.init(setState);
_idFocusNode.addListener(onFocusChanged);
if (_idController.text.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final lastRemoteId = await bind.mainGetLastRemoteId();
if (lastRemoteId != _idController.id) {
setState(() {
_idController.id = lastRemoteId;
});
}
});
}
Get.put<TextEditingController>(_idEditingController);
}
@override
Widget build(BuildContext context) {
Provider.of<FfiModel>(context);
return CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildListDelegate([
if (!bind.isCustomClient() && !isIOS)
Obx(() => _buildUpdateUI(stateGlobal.updateUrl.value)),
_buildRemoteIDTextField(),
])),
SliverFillRemaining(
hasScrollBody: true,
child: PeerTabPage(),
)
],
).marginOnly(top: 2, left: 10, right: 10);
}
/// Callback for the connect button.
/// Connects to the selected peer.
void onConnect() {
var id = _idController.id;
connect(context, id);
}
void onFocusChanged() {
_idEmpty.value = _idEditingController.text.isEmpty;
if (_idFocusNode.hasFocus) {
if (_allPeersLoader.needLoad) {
_allPeersLoader.getAllPeers();
}
final textLength = _idEditingController.value.text.length;
// Select all to facilitate removing text, just following the behavior of address input of chrome.
_idEditingController.selection =
TextSelection(baseOffset: 0, extentOffset: textLength);
}
}
/// UI for software update.
/// If _updateUrl] is not empty, shows a button to update the software.
Widget _buildUpdateUI(String updateUrl) {
return updateUrl.isEmpty
? const SizedBox(height: 0)
: InkWell(
onTap: () async {
final url = 'https://rustdesk.com/download';
// https://pub.dev/packages/url_launcher#configuration
// https://developer.android.com/training/package-visibility/use-cases#open-urls-custom-tabs
//
// `await launchUrl(Uri.parse(url))` can also run if skip
// 1. The following check
// 2. `<action android:name="android.support.customtabs.action.CustomTabsService" />` in AndroidManifest.xml
//
// But it is better to add the check.
await launchUrl(Uri.parse(url));
},
child: Container(
alignment: AlignmentDirectional.center,
width: double.infinity,
color: Colors.pinkAccent,
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(translate('Download new version'),
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold))));
}
/// UI for the remote ID TextField.
/// Search for a peer and connect to it if the id exists.
Widget _buildRemoteIDTextField() {
final w = SizedBox(
height: 84,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2),
child: Ink(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.all(Radius.circular(13)),
),
child: Row(
children: <Widget>[
Expanded(
child: Container(
padding: const EdgeInsets.only(left: 16, right: 16),
child: RawAutocomplete<Peer>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text == '') {
_autocompleteOpts = const Iterable<Peer>.empty();
} else if (_allPeersLoader.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded) {
Peer emptyPeer = Peer(
id: '',
username: '',
hostname: '',
alias: '',
platform: '',
tags: [],
hash: '',
password: '',
forceAlwaysRelay: false,
rdpPort: '',
rdpUsername: '',
loginName: '',
device_group_name: '',
note: '',
);
_autocompleteOpts = [emptyPeer];
} else {
String textWithoutSpaces =
textEditingValue.text.replaceAll(" ", "");
if (int.tryParse(textWithoutSpaces) != null) {
textEditingValue = TextEditingValue(
text: textWithoutSpaces,
selection: textEditingValue.selection,
);
}
String textToFind = textEditingValue.text.toLowerCase();
_autocompleteOpts = _allPeersLoader.peers
.where((peer) =>
peer.id.toLowerCase().contains(textToFind) ||
peer.username
.toLowerCase()
.contains(textToFind) ||
peer.hostname
.toLowerCase()
.contains(textToFind) ||
peer.alias.toLowerCase().contains(textToFind))
.toList();
}
return _autocompleteOpts;
},
focusNode: _idFocusNode,
textEditingController: _idEditingController,
fieldViewBuilder: (BuildContext context,
TextEditingController fieldTextEditingController,
FocusNode fieldFocusNode,
VoidCallback onFieldSubmitted) {
updateTextAndPreserveSelection(
fieldTextEditingController, _idController.text);
return AutoSizeTextField(
controller: fieldTextEditingController,
focusNode: fieldFocusNode,
minFontSize: 18,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
// keyboardType: TextInputType.number,
onChanged: (String text) {
_idController.id = text;
},
style: const TextStyle(
fontFamily: 'WorkSans',
fontWeight: FontWeight.bold,
fontSize: 30,
color: MyTheme.idColor,
),
decoration: InputDecoration(
labelText: translate('Remote ID'),
// hintText: 'Enter your remote ID',
border: InputBorder.none,
helperStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: MyTheme.darkGray,
),
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
letterSpacing: 0.2,
color: MyTheme.darkGray,
),
),
inputFormatters: [IDTextInputFormatter()],
onSubmitted: (_) {
onConnect();
},
);
},
onSelected: (option) {
setState(() {
_idController.id = option.id;
FocusScope.of(context).unfocus();
});
},
optionsViewBuilder: (BuildContext context,
AutocompleteOnSelected<Peer> onSelected,
Iterable<Peer> options) {
options = _autocompleteOpts;
double maxHeight = options.length * 50;
if (options.length == 1) {
maxHeight = 52;
} else if (options.length == 3) {
maxHeight = 146;
} else if (options.length == 4) {
maxHeight = 193;
}
maxHeight = maxHeight.clamp(0, 200);
return Align(
alignment: Alignment.topLeft,
child: Container(
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 5,
spreadRadius: 1,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxHeight,
maxWidth: 320,
),
child: _allPeersLoader
.peers.isEmpty &&
!_allPeersLoader.isPeersLoaded
? Container(
height: 80,
child: Center(
child:
CircularProgressIndicator(
strokeWidth: 2,
)))
: ListView(
padding:
EdgeInsets.only(top: 5),
children: options
.map((peer) =>
AutocompletePeerTile(
onSelect: () =>
onSelected(
peer),
peer: peer))
.toList(),
))))));
},
),
),
),
Obx(() => Offstage(
offstage: _idEmpty.value,
child: IconButton(
onPressed: () {
setState(() {
_idController.clear();
});
},
icon: Icon(Icons.clear, color: MyTheme.darkGray)),
)),
SizedBox(
width: 60,
height: 60,
child: IconButton(
icon: const Icon(Icons.arrow_forward,
color: MyTheme.darkGray, size: 45),
onPressed: onConnect,
),
),
],
),
),
),
);
final child = Column(children: [
if (isWebDesktop)
getConnectionPageTitle(context, true)
.marginOnly(bottom: 10, top: 15, left: 12),
w
]);
return Align(
alignment: Alignment.topCenter,
child: Container(constraints: kMobilePageConstraints, child: child));
}
@override
void dispose() {
_uniLinksSubscription?.cancel();
_idController.dispose();
_idFocusNode.removeListener(onFocusChanged);
_allPeersLoader.clear();
_idFocusNode.dispose();
_idEditingController.dispose();
if (Get.isRegistered<IDTextEditingController>()) {
Get.delete<IDTextEditingController>();
}
if (Get.isRegistered<TextEditingController>()) {
Get.delete<TextEditingController>();
}
super.dispose();
}
}
@@ -0,0 +1,769 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_breadcrumb/flutter_breadcrumb.dart';
import 'package:flutter_hbb/models/file_model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
class FileManagerPage extends StatefulWidget {
FileManagerPage(
{Key? key,
required this.id,
this.password,
this.isSharedPassword,
this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<StatefulWidget> createState() => _FileManagerPageState();
}
enum SelectMode { local, remote, none }
extension SelectModeEq on SelectMode {
bool eq(bool? currentIsLocal) {
if (currentIsLocal == null) {
return false;
}
if (currentIsLocal) {
return this == SelectMode.local;
} else {
return this == SelectMode.remote;
}
}
}
extension SelectModeExt on Rx<SelectMode> {
void toggle(bool currentIsLocal) {
switch (value) {
case SelectMode.local:
value = SelectMode.none;
break;
case SelectMode.remote:
value = SelectMode.none;
break;
case SelectMode.none:
if (currentIsLocal) {
value = SelectMode.local;
} else {
value = SelectMode.remote;
}
break;
}
}
}
class _FileManagerPageState extends State<FileManagerPage> {
final model = gFFI.fileModel;
final selectMode = SelectMode.none.obs;
var showLocal = true;
FileController get currentFileController =>
showLocal ? model.localController : model.remoteController;
FileDirectory get currentDir => currentFileController.directory.value;
DirectoryOptions get currentOptions => currentFileController.options.value;
final _uniqueKey = UniqueKey();
@override
void initState() {
super.initState();
gFFI.start(widget.id,
isFileTransfer: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay);
WidgetsBinding.instance.addPostFrameCallback((_) {
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
gFFI.ffiModel.updateEventListener(gFFI.sessionId, widget.id);
WakelockManager.enable(_uniqueKey);
}
@override
void dispose() {
model.close().whenComplete(() {
gFFI.close();
gFFI.dialogManager.dismissAll();
WakelockManager.disable(_uniqueKey);
});
model.jobController.clear();
super.dispose();
}
@override
Widget build(BuildContext context) => WillPopScope(
onWillPop: () async {
if (selectMode.value != SelectMode.none) {
selectMode.value = SelectMode.none;
setState(() {});
} else {
currentFileController.goBack();
}
return false;
},
child: Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
leading: Row(children: [
IconButton(
icon: Icon(Icons.close),
onPressed: () => clientClose(gFFI.sessionId, gFFI)),
]),
centerTitle: true,
title: ToggleSwitch(
initialLabelIndex: showLocal ? 0 : 1,
activeBgColor: [MyTheme.idColor],
inactiveBgColor: Theme.of(context).brightness == Brightness.light
? MyTheme.grayBg
: null,
inactiveFgColor: Theme.of(context).brightness == Brightness.light
? Colors.black54
: null,
totalSwitches: 2,
minWidth: 100,
fontSize: 15,
iconSize: 18,
labels: [translate("Local"), translate("Remote")],
icons: [Icons.phone_android_sharp, Icons.screen_share],
onToggle: (index) {
final current = showLocal ? 0 : 1;
if (index != current) {
setState(() => showLocal = !showLocal);
}
},
),
actions: [
PopupMenuButton<String>(
tooltip: "",
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Row(
children: [
Icon(Icons.refresh,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Refresh File"))
],
),
value: "refresh",
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
children: [
Icon(Icons.check,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Multi Select"))
],
),
value: "select",
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
children: [
Icon(Icons.folder_outlined,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Create Folder"))
],
),
value: "folder",
),
PopupMenuItem(
enabled: currentDir.path != "/",
child: Row(
children: [
Icon(
currentOptions.showHidden
? Icons.check_box_outlined
: Icons.check_box_outline_blank,
color: Theme.of(context).iconTheme.color),
SizedBox(width: 5),
Text(translate("Show Hidden Files"))
],
),
value: "hidden",
)
];
},
onSelected: (v) {
if (v == "refresh") {
currentFileController.refresh();
} else if (v == "select") {
model.localController.selectedItems.clear();
model.remoteController.selectedItems.clear();
selectMode.toggle(showLocal);
setState(() {});
} else if (v == "folder") {
final name = TextEditingController();
String? errorText;
gFFI.dialogManager.show((setState, close, context) {
name.addListener(() {
if (errorText != null) {
setState(() {
errorText = null;
});
}
});
return CustomAlertDialog(
title: Text(translate("Create Folder")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: InputDecoration(
labelText:
translate("Please enter the folder name"),
errorText: errorText,
),
controller: name,
).workaroundFreezeLinuxMint(),
],
),
actions: [
dialogButton("Cancel",
onPressed: () => close(false), isOutline: true),
dialogButton("OK", onPressed: () {
if (name.value.text.isNotEmpty) {
if (!PathUtil.validName(
name.value.text,
currentFileController
.options.value.isWindows)) {
setState(() {
errorText =
translate("Invalid folder name");
});
return;
}
currentFileController.createDir(PathUtil.join(
currentDir.path,
name.value.text,
currentOptions.isWindows));
close();
}
})
]);
});
} else if (v == "hidden") {
currentFileController.toggleShowHidden();
}
}),
],
),
body: showLocal
? FileManagerView(
controller: model.localController,
selectMode: selectMode,
)
: FileManagerView(
controller: model.remoteController,
selectMode: selectMode,
),
bottomSheet: bottomSheet(),
));
Widget? bottomSheet() {
return Obx(() {
final selectedItems = getActiveSelectedItems();
final jobTable = model.jobController.jobTable;
final localLabel = selectedItems?.isLocal == null
? ""
: " [${selectedItems!.isLocal ? translate("Local") : translate("Remote")}]";
if (!(selectMode.value == SelectMode.none)) {
final selectedItemsLen =
"${selectedItems?.items.length ?? 0} ${translate("items")}";
if (selectedItems == null ||
selectedItems.items.isEmpty ||
selectMode.value.eq(showLocal)) {
return BottomSheetBody(
leading: Icon(Icons.check),
title: translate("Selected"),
text: selectedItemsLen + localLabel,
onCanceled: () {
selectedItems?.items.clear();
selectMode.value = SelectMode.none;
setState(() {});
},
actions: [
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),
),
IconButton(
icon: Icon(Icons.delete_forever),
onPressed: selectedItems != null
? () async {
if (selectedItems.items.isNotEmpty) {
await currentFileController
.removeAction(selectedItems);
selectedItems.items.clear();
selectMode.value = SelectMode.none;
}
}
: null,
)
]);
} else {
return BottomSheetBody(
leading: Icon(Icons.input),
title: translate("Paste here?"),
text: selectedItemsLen + localLabel,
onCanceled: () {
selectedItems.items.clear();
selectMode.value = SelectMode.none;
setState(() {});
},
actions: [
IconButton(
icon: Icon(Icons.compare_arrows),
onPressed: () => setState(() => showLocal = !showLocal),
),
IconButton(
icon: Icon(Icons.paste),
onPressed: () {
selectMode.value = SelectMode.none;
final otherSide = showLocal
? model.remoteController
: model.localController;
final thisSideData =
DirectoryData(currentDir, currentOptions);
otherSide.sendFiles(selectedItems, thisSideData);
selectedItems.items.clear();
selectMode.value = SelectMode.none;
},
)
]);
}
}
if (jobTable.isEmpty) {
return Offstage();
}
// Find the first job that is in progress (the one actually transferring data)
// Rust backend processes jobs sequentially, so the first inProgress job is the active one
final activeJob = jobTable
.firstWhereOrNull((job) => job.state == JobState.inProgress) ??
jobTable.last;
switch (activeJob.state) {
case JobState.inProgress:
return BottomSheetBody(
leading: CircularProgressIndicator(),
title: translate("Waiting"),
text:
"${translate("Speed")}: ${readableFileSize(activeJob.speed)}/s",
onCanceled: () {
model.jobController.cancelJob(activeJob.id);
jobTable.clear();
},
);
case JobState.done:
return BottomSheetBody(
leading: Icon(Icons.check),
title: "${translate("Successful")}!",
text: activeJob.display(),
onCanceled: () => jobTable.clear(),
);
case JobState.error:
return BottomSheetBody(
leading: Icon(Icons.error),
title: "${translate("Error")}!",
text: "",
onCanceled: () => jobTable.clear(),
);
case JobState.none:
break;
case JobState.paused:
// TODO: Handle this case.
break;
}
return Offstage();
});
}
SelectedItems? getActiveSelectedItems() {
final localSelectedItems = model.localController.selectedItems;
final remoteSelectedItems = model.remoteController.selectedItems;
if (localSelectedItems.items.isNotEmpty &&
remoteSelectedItems.items.isNotEmpty) {
// assert unreachable
debugPrint("Wrong SelectedItems state, reset");
localSelectedItems.clear();
remoteSelectedItems.clear();
}
if (localSelectedItems.items.isEmpty && remoteSelectedItems.items.isEmpty) {
return null;
}
if (localSelectedItems.items.length > remoteSelectedItems.items.length) {
return localSelectedItems;
} else {
return remoteSelectedItems;
}
}
}
class FileManagerView extends StatefulWidget {
final FileController controller;
final Rx<SelectMode> selectMode;
FileManagerView({required this.controller, required this.selectMode});
@override
State<StatefulWidget> createState() => _FileManagerViewState();
}
class _FileManagerViewState extends State<FileManagerView> {
final _listScrollController = ScrollController();
final _breadCrumbScroller = ScrollController();
late final ascending = Rx<bool>(controller.sortAscending);
bool get isLocal => widget.controller.isLocal;
FileController get controller => widget.controller;
SelectedItems get _selectedItems => widget.controller.selectedItems;
@override
void initState() {
super.initState();
controller.directory.listen((e) => breadCrumbScrollToEnd());
}
@override
Widget build(BuildContext context) {
return Column(children: [
headTools(),
Expanded(child: Obx(() {
final entries = controller.directory.value.entries;
return ListView.builder(
controller: _listScrollController,
itemCount: entries.length + 1,
itemBuilder: (context, index) {
if (index >= entries.length) {
return listTail();
}
var selected = false;
if (widget.selectMode.value != SelectMode.none) {
selected = _selectedItems.items.contains(entries[index]);
}
final sizeStr = entries[index].isFile
? readableFileSize(entries[index].size.toDouble())
: "";
final showCheckBox = () {
return widget.selectMode.value != SelectMode.none &&
widget.selectMode.value.eq(controller.selectedItems.isLocal);
}();
return Card(
child: ListTile(
leading: entries[index].isDrive
? Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Image(
image: iconHardDrive,
fit: BoxFit.scaleDown,
color: Theme.of(context)
.iconTheme
.color
?.withOpacity(0.7)))
: Icon(
entries[index].isFile
? Icons.feed_outlined
: Icons.folder,
size: 40),
title: Text(entries[index].name),
selected: selected,
subtitle: entries[index].isDrive
? null
: Text(
"${entries[index].lastModified().toString().replaceAll(".000", "")} $sizeStr",
style: TextStyle(fontSize: 12, color: MyTheme.darkGray),
),
trailing: entries[index].isDrive
? null
: showCheckBox
? Checkbox(
value: selected,
onChanged: (v) {
if (v == null) return;
if (v && !selected) {
_selectedItems.add(entries[index]);
} else if (!v && selected) {
_selectedItems.remove(entries[index]);
}
setState(() {});
})
: PopupMenuButton<String>(
tooltip: "",
icon: Icon(Icons.more_vert),
itemBuilder: (context) {
return [
PopupMenuItem(
child: Text(translate("Delete")),
value: "delete",
),
PopupMenuItem(
child: Text(translate("Multi Select")),
value: "multi_select",
),
PopupMenuItem(
child: Text(translate("Properties")),
value: "properties",
enabled: false,
),
if (!entries[index].isDrive &&
versionCmp(gFFI.ffiModel.pi.version,
"1.3.0") >=
0)
PopupMenuItem(
child: Text(translate("Rename")),
value: "rename",
)
];
},
onSelected: (v) {
if (v == "delete") {
final items = SelectedItems(isLocal: isLocal);
items.add(entries[index]);
controller.removeAction(items);
} else if (v == "multi_select") {
_selectedItems.clear();
widget.selectMode.toggle(isLocal);
setState(() {});
} else if (v == "rename") {
controller.renameAction(
entries[index], isLocal);
}
}),
onTap: () {
if (showCheckBox) {
if (selected) {
_selectedItems.remove(entries[index]);
} else {
_selectedItems.add(entries[index]);
}
setState(() {});
return;
}
if (entries[index].isDirectory || entries[index].isDrive) {
controller.openDirectory(entries[index].path);
} else {
// Perform file-related tasks.
}
},
onLongPress: entries[index].isDrive
? null
: () {
_selectedItems.clear();
widget.selectMode.toggle(isLocal);
if (widget.selectMode.value != SelectMode.none) {
_selectedItems.add(entries[index]);
}
setState(() {});
},
),
);
},
);
}))
]);
}
void breadCrumbScrollToEnd() {
Future.delayed(Duration(milliseconds: 200), () {
if (_breadCrumbScroller.hasClients) {
_breadCrumbScroller.animateTo(
_breadCrumbScroller.position.maxScrollExtent,
duration: Duration(milliseconds: 200),
curve: Curves.fastLinearToSlowEaseIn);
}
});
}
Widget headTools() => Container(
child: Row(
children: [
Expanded(child: Obx(() {
final home = controller.options.value.home;
final isWindows = controller.options.value.isWindows;
return BreadCrumb(
items: getPathBreadCrumbItems(controller.shortPath, isWindows,
() => controller.goToHomeDirectory(), (list) {
var path = "";
if (home.startsWith(list[0])) {
// absolute path
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
} else {
path += home;
for (var item in list) {
path = PathUtil.join(path, item, isWindows);
}
}
controller.openDirectory(path);
}),
divider: Icon(Icons.chevron_right),
overflow: ScrollableOverflow(controller: _breadCrumbScroller),
);
})),
Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back),
onPressed: controller.goBack,
),
IconButton(
icon: Icon(Icons.arrow_upward),
onPressed: controller.goToParentDirectory,
),
PopupMenuButton<SortBy>(
tooltip: "",
icon: Icon(Icons.sort),
itemBuilder: (context) {
return SortBy.values
.map((e) => PopupMenuItem(
child: Text(translate(e.toString())),
value: e,
))
.toList();
},
onSelected: (sortBy) {
// If selecting the same sort option, flip the order
// If selecting a different sort option, use ascending order
if (controller.sortBy.value == sortBy) {
ascending.value = !controller.sortAscending;
} else {
ascending.value = true;
}
controller.changeSortStyle(sortBy,
ascending: ascending.value);
}),
],
)
],
));
Widget listTail() => Obx(() => Container(
height: 100,
child: Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(30, 5, 30, 0),
child: Text(
controller.directory.value.path,
style: TextStyle(color: MyTheme.darkGray),
),
),
Padding(
padding: EdgeInsets.all(2),
child: Text(
"${translate("Total")}: ${controller.directory.value.entries.length} ${translate("items")}",
style: TextStyle(color: MyTheme.darkGray),
),
)
],
),
));
List<BreadCrumbItem> getPathBreadCrumbItems(String shortPath, bool isWindows,
void Function() onHome, void Function(List<String>) onPressed) {
final list = PathUtil.split(shortPath, isWindows);
final breadCrumbList = [
BreadCrumbItem(
content: IconButton(
icon: Icon(Icons.home_filled),
onPressed: onHome,
))
];
breadCrumbList.addAll(list.asMap().entries.map((e) => BreadCrumbItem(
content: TextButton(
child: Text(e.value),
style:
ButtonStyle(minimumSize: MaterialStateProperty.all(Size(0, 0))),
onPressed: () => onPressed(list.sublist(0, e.key + 1))))));
return breadCrumbList;
}
}
class BottomSheetBody extends StatelessWidget {
BottomSheetBody(
{required this.leading,
required this.title,
required this.text,
this.onCanceled,
this.actions});
final Widget leading;
final String title;
final String text;
final VoidCallback? onCanceled;
final List<IconButton>? actions;
@override
BottomSheet build(BuildContext context) {
// ignore: no_leading_underscores_for_local_identifiers
final _actions = actions ?? [];
return BottomSheet(
builder: (BuildContext context) {
return Container(
height: 65,
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: MyTheme.accent50,
borderRadius: BorderRadius.vertical(top: Radius.circular(10))),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
leading,
SizedBox(width: 16),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 18)),
Text(text,
style: TextStyle(fontSize: 14)) // TODO color
],
)
],
),
Row(children: () {
_actions.add(IconButton(
icon: Icon(Icons.cancel_outlined),
onPressed: onCanceled,
));
return _actions;
}())
],
),
));
},
onClosing: () {},
// backgroundColor: MyTheme.grayBg,
enableDrag: false,
);
}
}
+255
View File
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/mobile/pages/server_page.dart';
import 'package:flutter_hbb/mobile/pages/settings_page.dart';
import 'package:flutter_hbb/web/settings_page.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../common/widgets/chat_page.dart';
import '../../models/platform_model.dart';
import '../../models/state_model.dart';
import 'connection_page.dart';
abstract class PageShape extends Widget {
final String title = "";
final Widget icon = Icon(null);
final List<Widget> appBarActions = [];
}
class HomePage extends StatefulWidget {
static final homeKey = GlobalKey<HomePageState>();
HomePage() : super(key: homeKey);
@override
HomePageState createState() => HomePageState();
}
class HomePageState extends State<HomePage> {
var _selectedIndex = 0;
int get selectedIndex => _selectedIndex;
final List<PageShape> _pages = [];
int _chatPageTabIndex = -1;
bool get isChatPageCurrentTab => isAndroid
? _selectedIndex == _chatPageTabIndex
: false; // change this when ios have chat page
void refreshPages() {
setState(() {
initPages();
});
}
@override
void initState() {
super.initState();
initPages();
}
void initPages() {
_pages.clear();
if (!bind.isIncomingOnly()) {
_pages.add(ConnectionPage(
appBarActions: [],
));
}
if (isAndroid && !bind.isOutgoingOnly()) {
_chatPageTabIndex = _pages.length;
_pages.addAll([ChatPage(type: ChatPageType.mobileMain), ServerPage()]);
}
_pages.add(SettingsPage());
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
if (_selectedIndex != 0) {
setState(() {
_selectedIndex = 0;
});
} else {
return true;
}
return false;
},
child: Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: appTitle(),
actions: _pages.elementAt(_selectedIndex).appBarActions,
),
bottomNavigationBar: BottomNavigationBar(
key: navigationBarKey,
items: _pages
.map((page) =>
BottomNavigationBarItem(icon: page.icon, label: page.title))
.toList(),
currentIndex: _selectedIndex,
type: BottomNavigationBarType.fixed,
selectedItemColor: MyTheme.accent, //
unselectedItemColor: MyTheme.darkGray,
onTap: (index) => setState(() {
// close chat overlay when go chat page
if (_selectedIndex != index) {
_selectedIndex = index;
if (isChatPageCurrentTab) {
gFFI.chatModel.hideChatIconOverlay();
gFFI.chatModel.hideChatWindowOverlay();
gFFI.chatModel.mobileClearClientUnread(
gFFI.chatModel.currentKey.connId);
}
}
}),
),
body: _pages.elementAt(_selectedIndex),
));
}
Widget appTitle() {
final currentUser = gFFI.chatModel.currentUser;
final currentKey = gFFI.chatModel.currentKey;
if (isChatPageCurrentTab &&
currentUser != null &&
currentKey.peerId.isNotEmpty) {
final connected =
gFFI.serverModel.clients.any((e) => e.id == currentKey.connId);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Tooltip(
message: currentKey.isOut
? translate('Outgoing connection')
: translate('Incoming connection'),
child: Icon(
currentKey.isOut
? Icons.call_made_rounded
: Icons.call_received_rounded,
),
),
Expanded(
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${currentUser.firstName} ${currentUser.id}",
),
if (connected)
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color.fromARGB(255, 133, 246, 199)),
).marginSymmetric(horizontal: 2),
],
),
),
),
],
);
}
return Text(bind.mainGetAppNameSync());
}
}
class WebHomePage extends StatelessWidget {
final connectionPage =
ConnectionPage(appBarActions: <Widget>[const WebSettingsPage()]);
@override
Widget build(BuildContext context) {
stateGlobal.isInMainPage = true;
handleUnilink(context);
return Scaffold(
// backgroundColor: MyTheme.grayBg,
appBar: AppBar(
centerTitle: true,
title: Text("${bind.mainGetAppNameSync()} (Preview)"),
actions: connectionPage.appBarActions,
),
body: connectionPage,
);
}
handleUnilink(BuildContext context) {
if (webInitialLink.isEmpty) {
return;
}
final link = webInitialLink;
webInitialLink = '';
final splitter = ["/#/", "/#", "#/", "#"];
var fakelink = '';
for (var s in splitter) {
if (link.contains(s)) {
var list = link.split(s);
if (list.length < 2 || list[1].isEmpty) {
return;
}
list.removeAt(0);
fakelink = "rustdesk://${list.join(s)}";
break;
}
}
if (fakelink.isEmpty) {
return;
}
final uri = Uri.tryParse(fakelink);
if (uri == null) {
return;
}
final args = urlLinkToCmdArgs(uri);
if (args == null || args.isEmpty) {
return;
}
bool isFileTransfer = false;
bool isViewCamera = false;
bool isTerminal = false;
String? id;
String? password;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case '--connect':
case '--play':
id = args[i + 1];
i++;
break;
case '--file-transfer':
isFileTransfer = true;
id = args[i + 1];
i++;
break;
case '--view-camera':
isViewCamera = true;
id = args[i + 1];
i++;
break;
case '--terminal':
isTerminal = true;
id = args[i + 1];
i++;
break;
case '--terminal-admin':
setEnvTerminalAdmin();
isTerminal = true;
id = args[i + 1];
i++;
break;
case '--password':
password = args[i + 1];
i++;
break;
default:
break;
}
}
if (id != null) {
connect(context, id,
isFileTransfer: isFileTransfer,
isViewCamera: isViewCamera,
isTerminal: isTerminal,
password: password);
}
}
}
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image/image.dart' as img;
import 'package:image_picker/image_picker.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:zxing2/qrcode.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
import '../widgets/dialog.dart';
class ScanPage extends StatefulWidget {
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
QRViewController? controller;
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
StreamSubscription? scanSubscription;
@override
void reassemble() {
super.reassemble();
if (isAndroid && controller != null) {
controller!.pauseCamera();
} else if (controller != null) {
controller!.resumeCamera();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR'),
actions: [
_buildImagePickerButton(),
_buildFlashToggleButton(),
_buildCameraSwitchButton(),
],
),
body: _buildQrView(context),
);
}
Widget _buildQrView(BuildContext context) {
var scanArea = MediaQuery.of(context).size.width < 400 ||
MediaQuery.of(context).size.height < 400
? 150.0
: 300.0;
return QRView(
key: qrKey,
onQRViewCreated: _onQRViewCreated,
overlay: QrScannerOverlayShape(
borderColor: Colors.red,
borderRadius: 10,
borderLength: 30,
borderWidth: 10,
cutOutSize: scanArea,
),
onPermissionSet: (ctrl, p) => _onPermissionSet(context, ctrl, p),
);
}
void _onQRViewCreated(QRViewController controller) {
setState(() {
this.controller = controller;
});
scanSubscription = controller.scannedDataStream.listen((scanData) {
if (scanData.code != null) {
showServerSettingFromQr(scanData.code!);
}
});
}
void _onPermissionSet(BuildContext context, QRViewController ctrl, bool p) {
if (!p) {
showToast('No permission');
}
}
Future<void> _pickImage() async {
final ImagePicker picker = ImagePicker();
final XFile? file = await picker.pickImage(source: ImageSource.gallery);
if (file != null) {
try {
var image = img.decodeImage(await File(file.path).readAsBytes())!;
LuminanceSource source = RGBLuminanceSource(
image.width,
image.height,
image.getBytes(order: img.ChannelOrder.abgr).buffer.asInt32List(),
);
var bitmap = BinaryBitmap(HybridBinarizer(source));
var reader = QRCodeReader();
var result = reader.decode(bitmap);
if (result.text.startsWith(bind.mainUriPrefixSync())) {
handleUriLink(uriString: result.text);
} else {
showServerSettingFromQr(result.text);
}
} catch (e) {
showToast('No QR code found');
}
}
}
Widget _buildImagePickerButton() {
return IconButton(
color: Colors.white,
icon: Icon(Icons.image_search),
iconSize: 32.0,
onPressed: _pickImage,
);
}
Widget _buildFlashToggleButton() {
return IconButton(
color: Colors.yellow,
icon: Icon(Icons.flash_on),
iconSize: 32.0,
onPressed: () async {
await controller?.toggleFlash();
},
);
}
Widget _buildCameraSwitchButton() {
return IconButton(
color: Colors.white,
icon: Icon(Icons.switch_camera),
iconSize: 32.0,
onPressed: () async {
await controller?.flipCamera();
},
);
}
@override
void dispose() {
scanSubscription?.cancel();
controller?.dispose();
super.dispose();
}
void showServerSettingFromQr(String data) async {
closeConnection();
await controller?.pauseCamera();
if (!data.startsWith('config=')) {
showToast('Invalid QR code');
return;
}
try {
final sc = ServerConfig.decode(data.substring(7));
Timer(Duration(milliseconds: 60), () {
showServerSettingsWithValue(sc, gFFI.dialogManager, null);
});
} catch (e) {
showToast('Invalid QR code');
}
}
}
+965
View File
@@ -0,0 +1,965 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/mobile/widgets/dialog.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import '../../common.dart';
import '../../common/widgets/dialog.dart';
import '../../consts.dart';
import '../../models/platform_model.dart';
import '../../models/server_model.dart';
import 'home_page.dart';
class ServerPage extends StatefulWidget implements PageShape {
@override
final title = translate("Share screen");
@override
final icon = const Icon(Icons.mobile_screen_share);
@override
final appBarActions = (!bind.isDisableSettings() &&
bind.mainGetBuildinOption(key: kOptionHideSecuritySetting) != 'Y')
? [_DropDownAction()]
: [];
ServerPage({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _ServerPageState();
}
class _DropDownAction extends StatelessWidget {
_DropDownAction();
// should only have one action
final actions = [
PopupMenuButton<String>(
tooltip: "",
icon: const Icon(Icons.more_vert),
itemBuilder: (context) {
listTile(String text, bool checked) {
return ListTile(
title: Text(translate(text)),
trailing: Icon(
Icons.check,
color: checked ? null : Colors.transparent,
));
}
final approveMode = gFFI.serverModel.approveMode;
final verificationMethod = gFFI.serverModel.verificationMethod;
final showPasswordOption = approveMode != 'click';
final isApproveModeFixed = isOptionFixed(kOptionApproveMode);
final isNumericOneTimePasswordFixed =
isOptionFixed(kOptionAllowNumericOneTimePassword);
final isAllowNumericOneTimePassword =
gFFI.serverModel.allowNumericOneTimePassword;
return [
if (!isChangeIdDisabled())
PopupMenuItem(
enabled: gFFI.serverModel.connectStatus > 0,
value: "changeID",
child: Text(translate("Change ID")),
),
if (!isChangeIdDisabled()) const PopupMenuDivider(),
PopupMenuItem(
value: 'AcceptSessionsViaPassword',
child: listTile(
'Accept sessions via password', approveMode == 'password'),
enabled: !isApproveModeFixed,
),
PopupMenuItem(
value: 'AcceptSessionsViaClick',
child:
listTile('Accept sessions via click', approveMode == 'click'),
enabled: !isApproveModeFixed,
),
PopupMenuItem(
value: "AcceptSessionsViaBoth",
child: listTile("Accept sessions via both",
approveMode != 'password' && approveMode != 'click'),
enabled: !isApproveModeFixed,
),
if (showPasswordOption) const PopupMenuDivider(),
if (showPasswordOption &&
verificationMethod != kUseTemporaryPassword &&
!isChangePermanentPasswordDisabled())
PopupMenuItem(
value: "setPermanentPassword",
child: Text(translate("Set permanent password")),
),
if (showPasswordOption &&
verificationMethod != kUsePermanentPassword)
PopupMenuItem(
value: "setTemporaryPasswordLength",
child: Text(translate("One-time password length")),
),
if (showPasswordOption &&
verificationMethod != kUsePermanentPassword)
PopupMenuItem(
value: "allowNumericOneTimePassword",
child: listTile(translate("Numeric one-time password"),
isAllowNumericOneTimePassword),
enabled: !isNumericOneTimePasswordFixed,
),
if (showPasswordOption) const PopupMenuDivider(),
if (showPasswordOption)
PopupMenuItem(
value: kUseTemporaryPassword,
child: listTile('Use one-time password',
verificationMethod == kUseTemporaryPassword),
),
if (showPasswordOption)
PopupMenuItem(
value: kUsePermanentPassword,
child: listTile('Use permanent password',
verificationMethod == kUsePermanentPassword),
),
if (showPasswordOption)
PopupMenuItem(
value: kUseBothPasswords,
child: listTile(
'Use both passwords',
verificationMethod != kUseTemporaryPassword &&
verificationMethod != kUsePermanentPassword),
),
];
},
onSelected: (value) async {
if (value == "changeID") {
changeIdDialog();
} else if (value == "setPermanentPassword") {
setPasswordDialog();
} else if (value == "setTemporaryPasswordLength") {
setTemporaryPasswordLengthDialog(gFFI.dialogManager);
} else if (value == "allowNumericOneTimePassword") {
gFFI.serverModel.switchAllowNumericOneTimePassword();
gFFI.serverModel.updatePasswordModel();
} else if (value == kUsePermanentPassword ||
value == kUseTemporaryPassword ||
value == kUseBothPasswords) {
callback() {
bind.mainSetOption(key: kOptionVerificationMethod, value: value);
gFFI.serverModel.updatePasswordModel();
}
if (value == kUsePermanentPassword &&
(await bind.mainGetCommon(key: "permanent-password-set")) !=
"true") {
if (isChangePermanentPasswordDisabled()) {
callback();
return;
}
setPasswordDialog(notEmptyCallback: callback);
} else {
callback();
}
} else if (value.startsWith("AcceptSessionsVia")) {
value = value.substring("AcceptSessionsVia".length);
if (value == "Password") {
gFFI.serverModel.setApproveMode('password');
} else if (value == "Click") {
gFFI.serverModel.setApproveMode('click');
} else {
gFFI.serverModel.setApproveMode(defaultOptionApproveMode);
}
}
})
];
@override
Widget build(BuildContext context) {
return actions[0];
}
}
class _ServerPageState extends State<ServerPage> {
Timer? _updateTimer;
@override
void initState() {
super.initState();
_updateTimer = periodic_immediate(const Duration(seconds: 3), () async {
await gFFI.serverModel.fetchID();
});
gFFI.serverModel.checkAndroidPermission();
}
@override
void dispose() {
_updateTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
checkService();
return ChangeNotifierProvider.value(
value: gFFI.serverModel,
child: Consumer<ServerModel>(
builder: (context, serverModel, child) => SingleChildScrollView(
controller: gFFI.serverModel.controller,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
buildPresetPasswordWarningMobile(),
gFFI.serverModel.isStart
? ServerInfo()
: ServiceNotRunningNotification(),
const ConnectionManager(),
const PermissionChecker(),
SizedBox.fromSize(size: const Size(0, 15.0)),
],
),
),
)));
}
}
void checkService() async {
gFFI.invokeMethod("check_service");
// for Android 10/11, request MANAGE_EXTERNAL_STORAGE permission from system setting page
if (AndroidPermissionManager.isWaitingFile() && !gFFI.serverModel.fileOk) {
AndroidPermissionManager.complete(kManageExternalStorage,
await AndroidPermissionManager.check(kManageExternalStorage));
debugPrint("file permission finished");
}
}
class ServiceNotRunningNotification extends StatelessWidget {
ServiceNotRunningNotification({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
return PaddingCard(
title: translate("Service is not running"),
titleIcon:
const Icon(Icons.warning_amber_sharp, color: Colors.redAccent),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate("android_start_service_tip"),
style:
const TextStyle(fontSize: 12, color: MyTheme.darkGray))
.marginOnly(bottom: 8),
ElevatedButton.icon(
icon: const Icon(Icons.play_arrow),
onPressed: () {
if (gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") !=
"N") {
showScamWarning(context, serverModel);
} else {
serverModel.toggleService();
}
},
label: Text(translate("Start service")))
],
));
}
}
class ScamWarningDialog extends StatefulWidget {
final ServerModel serverModel;
ScamWarningDialog({required this.serverModel});
@override
ScamWarningDialogState createState() => ScamWarningDialogState();
}
class ScamWarningDialogState extends State<ScamWarningDialog> {
int _countdown = bind.isCustomClient() ? 0 : 12;
bool show_warning = false;
late Timer _timer;
late ServerModel _serverModel;
@override
void initState() {
super.initState();
_serverModel = widget.serverModel;
startCountdown();
}
void startCountdown() {
const oneSecond = Duration(seconds: 1);
_timer = Timer.periodic(oneSecond, (timer) {
setState(() {
_countdown--;
if (_countdown <= 0) {
timer.cancel();
}
});
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isButtonLocked = _countdown > 0;
return AlertDialog(
content: ClipRRect(
borderRadius: BorderRadius.circular(20.0),
child: SingleChildScrollView(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [
Color(0xffe242bc),
Color(0xfff4727c),
],
),
),
padding: EdgeInsets.all(25.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.warning_amber_sharp,
color: Colors.white,
),
SizedBox(width: 10),
Text(
translate("Warning"),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 20.0,
),
),
],
),
SizedBox(height: 20),
Center(
child: Image.asset(
'assets/scam.png',
width: 180,
),
),
SizedBox(height: 18),
Text(
translate("scam_title"),
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 22.0,
),
),
SizedBox(height: 18),
Text(
"${translate("scam_text1")}\n\n${translate("scam_text2")}\n",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
Row(
children: <Widget>[
Checkbox(
value: show_warning,
onChanged: (value) {
setState(() {
show_warning = value!;
});
},
),
Text(
translate("Don't show again"),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15.0,
),
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Container(
constraints: BoxConstraints(maxWidth: 150),
child: ElevatedButton(
onPressed: isButtonLocked
? null
: () {
Navigator.of(context).pop();
_serverModel.toggleService();
if (show_warning) {
bind.mainSetLocalOption(
key: "show-scam-warning", value: "N");
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
),
child: Text(
isButtonLocked
? "${translate("I Agree")} (${_countdown}s)"
: translate("I Agree"),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.0,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
SizedBox(width: 15),
Container(
constraints: BoxConstraints(maxWidth: 150),
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueAccent,
),
child: Text(
translate("Decline"),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.0,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
),
],
),
),
),
),
contentPadding: EdgeInsets.all(0.0),
);
}
}
class ServerInfo extends StatelessWidget {
final model = gFFI.serverModel;
final emptyController = TextEditingController(text: "-");
ServerInfo({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
const Color colorPositive = Colors.green;
const Color colorNegative = Colors.red;
const double iconMarginRight = 15;
const double iconSize = 24;
const TextStyle textStyleHeading = TextStyle(
fontSize: 16.0, fontWeight: FontWeight.bold, color: Colors.grey);
const TextStyle textStyleValue =
TextStyle(fontSize: 25.0, fontWeight: FontWeight.bold);
void copyToClipboard(String value) {
Clipboard.setData(ClipboardData(text: value));
showToast(translate('Copied'));
}
Widget ConnectionStateNotification() {
if (serverModel.connectStatus == -1) {
return Row(children: [
const Icon(Icons.warning_amber_sharp,
color: colorNegative, size: iconSize)
.marginOnly(right: iconMarginRight),
Expanded(child: Text(translate('not_ready_status')))
]);
} else if (serverModel.connectStatus == 0) {
return Row(children: [
SizedBox(width: 20, height: 20, child: CircularProgressIndicator())
.marginOnly(left: 4, right: iconMarginRight),
Expanded(child: Text(translate('connecting_status')))
]);
} else {
return Row(children: [
const Icon(Icons.check, color: colorPositive, size: iconSize)
.marginOnly(right: iconMarginRight),
Expanded(child: Text(translate('Ready')))
]);
}
}
final showOneTime = serverModel.approveMode != 'click' &&
serverModel.verificationMethod != kUsePermanentPassword;
return PaddingCard(
title: translate('Your Device'),
child: Column(
// ID
children: [
Row(children: [
const Icon(Icons.perm_identity,
color: Colors.grey, size: iconSize)
.marginOnly(right: iconMarginRight),
Text(
translate('ID'),
style: textStyleHeading,
)
]),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(
model.serverId.value.text,
style: textStyleValue,
),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(Icons.copy_outlined),
onPressed: () {
copyToClipboard(model.serverId.value.text.trim());
})
]).marginOnly(left: 39, bottom: 10),
// Password
Row(children: [
const Icon(Icons.lock_outline, color: Colors.grey, size: iconSize)
.marginOnly(right: iconMarginRight),
Text(
translate('One-time Password'),
style: textStyleHeading,
)
]),
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(
!showOneTime ? '-' : model.serverPasswd.value.text,
style: textStyleValue,
),
!showOneTime
? SizedBox.shrink()
: Row(children: [
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.refresh),
onPressed: () => bind.mainUpdateTemporaryPassword()),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(Icons.copy_outlined),
onPressed: () {
copyToClipboard(
model.serverPasswd.value.text.trim());
})
])
]).marginOnly(left: 40, bottom: 15),
ConnectionStateNotification()
],
));
}
}
class PermissionChecker extends StatefulWidget {
const PermissionChecker({Key? key}) : super(key: key);
@override
State<PermissionChecker> createState() => _PermissionCheckerState();
}
class _PermissionCheckerState extends State<PermissionChecker> {
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
final hasAudioPermission = androidVersion >= 30;
final hideStopService = isAndroid &&
bind.mainGetBuildinOption(key: kOptionHideStopService) == 'Y';
final allowPermChangeInAcceptWindow = option2bool(
kOptionEnablePermChangeInAcceptWindow,
bind.mainGetBuildinOption(
key: kOptionEnablePermChangeInAcceptWindow,
));
final permissionChangeLocked = isAndroid &&
serverModel.clients.any((c) => !c.disconnected) &&
!allowPermChangeInAcceptWindow;
return PaddingCard(
title: translate("Permissions"),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
serverModel.mediaOk && !hideStopService
? ElevatedButton.icon(
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Colors.red)),
icon: const Icon(Icons.stop),
onPressed: serverModel.toggleService,
label: Text(translate("Stop service")))
.marginOnly(bottom: 8)
: SizedBox.shrink(),
if (!hideStopService || !serverModel.mediaOk)
PermissionRow(
translate("Screen Capture"),
serverModel.mediaOk,
!serverModel.mediaOk &&
gFFI.userModel.userName.value.isEmpty &&
bind.mainGetLocalOption(key: "show-scam-warning") != "N"
? () => showScamWarning(context, serverModel)
: serverModel.toggleService),
PermissionRow(
translate("Input Control"),
serverModel.inputOk,
serverModel.toggleInput,
),
PermissionRow(
translate("Transfer file"),
serverModel.fileOk,
serverModel.toggleFile,
enabled: !permissionChangeLocked,
),
hasAudioPermission
? PermissionRow(translate("Audio Capture"), serverModel.audioOk,
serverModel.toggleAudio,
enabled: !permissionChangeLocked)
: Row(children: [
Icon(Icons.info_outline).marginOnly(right: 15),
Expanded(
child: Text(
translate("android_version_audio_tip"),
style: const TextStyle(color: MyTheme.darkGray),
))
]),
PermissionRow(
translate("Enable clipboard"),
serverModel.clipboardOk,
serverModel.toggleClipboard,
enabled: !permissionChangeLocked,
),
]));
}
}
class PermissionRow extends StatelessWidget {
const PermissionRow(this.name, this.isOk, this.onPressed,
{Key? key, this.enabled = true})
: super(key: key);
final String name;
final bool isOk;
final VoidCallback onPressed;
final bool enabled;
@override
Widget build(BuildContext context) {
return SwitchListTile(
visualDensity: VisualDensity.compact,
contentPadding: EdgeInsets.all(0),
title: Text(name),
value: isOk,
onChanged: enabled
? (bool value) {
onPressed();
}
: null);
}
}
class ConnectionManager extends StatelessWidget {
const ConnectionManager({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final serverModel = Provider.of<ServerModel>(context);
return Column(
children: serverModel.clients
.map((client) => PaddingCard(
title: translate(
client.isFileTransfer ? "Transfer file" : "Share screen"),
titleIcon: client.isFileTransfer
? Icon(Icons.folder_outlined)
: Icon(Icons.mobile_screen_share),
child: Column(children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: ClientInfo(client)),
Expanded(
flex: -1,
child: client.isFileTransfer || !client.authorized
? const SizedBox.shrink()
: IconButton(
onPressed: () {
gFFI.chatModel.changeCurrentKey(
MessageKey(client.peerId, client.id));
final bar = navigationBarKey.currentWidget;
if (bar != null) {
bar as BottomNavigationBar;
bar.onTap!(1);
}
},
icon: unreadTopRightBuilder(
client.unreadChatMessageCount)))
],
),
client.authorized
? const SizedBox.shrink()
: Text(
translate("android_new_connection_tip"),
style: Theme.of(context).textTheme.bodyMedium,
).marginOnly(bottom: 5),
client.authorized
? _buildDisconnectButton(client)
: _buildNewConnectionHint(serverModel, client),
if (client.incomingVoiceCall && !client.inVoiceCall)
..._buildNewVoiceCallHint(context, serverModel, client),
])))
.toList());
}
Widget _buildDisconnectButton(Client client) {
final disconnectButton = ElevatedButton.icon(
style: ButtonStyle(backgroundColor: MaterialStatePropertyAll(Colors.red)),
icon: const Icon(Icons.close),
onPressed: () {
bind.cmCloseConnection(connId: client.id);
gFFI.invokeMethod("cancel_notification", client.id);
},
label: Text(translate("Disconnect")),
);
final buttons = [disconnectButton];
if (client.inVoiceCall) {
buttons.insert(
0,
ElevatedButton.icon(
style: ButtonStyle(
backgroundColor: MaterialStatePropertyAll(Colors.red)),
icon: const Icon(Icons.phone),
label: Text(translate("Stop")),
onPressed: () {
bind.cmCloseVoiceCall(id: client.id);
gFFI.invokeMethod("cancel_notification", client.id);
},
),
);
}
if (buttons.length == 1) {
return Container(
alignment: Alignment.centerRight,
child: disconnectButton,
);
} else {
return Row(
children: buttons,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
);
}
}
Widget _buildNewConnectionHint(ServerModel serverModel, Client client) {
return Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(
child: Text(translate("Dismiss")),
onPressed: () {
serverModel.sendLoginResponse(client, false);
}).marginOnly(right: 15),
if (serverModel.approveMode != 'password')
ElevatedButton.icon(
icon: const Icon(Icons.check),
label: Text(translate("Accept")),
onPressed: () {
serverModel.sendLoginResponse(client, true);
}),
]);
}
List<Widget> _buildNewVoiceCallHint(
BuildContext context, ServerModel serverModel, Client client) {
return [
Text(
translate("android_new_voice_call_tip"),
style: Theme.of(context).textTheme.bodyMedium,
).marginOnly(bottom: 5),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
TextButton(
child: Text(translate("Dismiss")),
onPressed: () {
serverModel.handleVoiceCall(client, false);
}).marginOnly(right: 15),
if (serverModel.approveMode != 'password')
ElevatedButton.icon(
icon: const Icon(Icons.check),
label: Text(translate("Accept")),
onPressed: () {
serverModel.handleVoiceCall(client, true);
}),
])
];
}
}
class PaddingCard extends StatelessWidget {
const PaddingCard({Key? key, required this.child, this.title, this.titleIcon})
: super(key: key);
final String? title;
final Icon? titleIcon;
final Widget child;
@override
Widget build(BuildContext context) {
final children = [child];
if (title != null) {
children.insert(
0,
Padding(
padding: const EdgeInsets.fromLTRB(0, 5, 0, 8),
child: Row(
children: [
titleIcon?.marginOnly(right: 10) ?? const SizedBox.shrink(),
Expanded(
child: Text(title!,
style: Theme.of(context)
.textTheme
.titleLarge
?.merge(TextStyle(fontWeight: FontWeight.bold))),
)
],
)));
}
return SizedBox(
width: double.maxFinite,
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
margin: const EdgeInsets.fromLTRB(12.0, 10.0, 12.0, 0),
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 15.0, horizontal: 20.0),
child: Column(
children: children,
),
),
));
}
}
class ClientInfo extends StatelessWidget {
final Client client;
ClientInfo(this.client);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(children: [
Row(
children: [
Expanded(
flex: -1,
child: Padding(
padding: const EdgeInsets.only(right: 12),
child: _buildAvatar(context))),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(client.name, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 8),
Text(client.peerId, style: const TextStyle(fontSize: 10))
]))
],
),
]));
}
Widget _buildAvatar(BuildContext context) {
final fallback = CircleAvatar(
backgroundColor: str2color(client.name,
Theme.of(context).brightness == Brightness.light ? 255 : 150),
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
);
return buildAvatarWidget(
avatar: client.avatar,
size: 40,
fallback: fallback,
) ??
fallback;
}
}
void androidChannelInit() {
gFFI.setMethodCallHandler((method, arguments) {
debugPrint("flutter got android msg,$method,$arguments");
try {
switch (method) {
case "start_capture":
{
gFFI.dialogManager.dismissAll();
gFFI.serverModel.updateClientState();
break;
}
case "on_state_changed":
{
var name = arguments["name"] as String;
var value = arguments["value"] as String == "true";
debugPrint("from jvm:on_state_changed,$name:$value");
gFFI.serverModel.changeStatue(name, value);
break;
}
case "on_android_permission_result":
{
var type = arguments["type"] as String;
var result = arguments["result"] as bool;
AndroidPermissionManager.complete(type, result);
break;
}
case "on_media_projection_canceled":
{
gFFI.serverModel.stopService();
break;
}
case "msgbox":
{
var type = arguments["type"] as String;
var title = arguments["title"] as String;
var text = arguments["text"] as String;
var link = (arguments["link"] ?? '') as String;
msgBox(gFFI.sessionId, type, title, text, link, gFFI.dialogManager);
break;
}
case "stop_service":
{
print(
"stop_service by kotlin, isStart:${gFFI.serverModel.isStart}");
if (gFFI.serverModel.isStart) {
gFFI.serverModel.stopService();
}
break;
}
}
} catch (e) {
debugPrintStack(label: "MethodCallHandler err:$e");
}
return "";
});
}
void showScamWarning(BuildContext context, ServerModel serverModel) {
showDialog(
context: context,
builder: (BuildContext context) {
return ScamWarningDialog(serverModel: serverModel);
},
);
}
File diff suppressed because it is too large Load Diff
+441
View File
@@ -0,0 +1,441 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/dialog.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/terminal_model.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:xterm/xterm.dart';
import '../../desktop/pages/terminal_connection_manager.dart';
import '../../consts.dart';
class TerminalPage extends StatefulWidget {
const TerminalPage({
Key? key,
required this.id,
required this.password,
required this.isSharedPassword,
this.forceRelay,
this.connToken,
}) : super(key: key);
final String id;
final String? password;
final bool? forceRelay;
final bool? isSharedPassword;
final String? connToken;
final terminalId = 0;
@override
State<TerminalPage> createState() => _TerminalPageState();
}
class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin, WidgetsBindingObserver {
late FFI _ffi;
late TerminalModel _terminalModel;
double? _cellHeight;
double _sysKeyboardHeight = 0;
Timer? _keyboardDebounce;
final GlobalKey _keyboardKey = GlobalKey();
double _keyboardHeight = 0;
late bool _showTerminalExtraKeys;
// For iOS edge swipe gesture
double _swipeStartX = 0;
double _swipeCurrentX = 0;
// For web only.
// 'monospace' does not work on web, use Google Fonts, `??` is only for null safety.
final String _robotoMonoFontFamily = isWeb
? (GoogleFonts.robotoMono().fontFamily ?? 'monospace')
: 'monospace';
SessionID get sessionId => _ffi.sessionId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
debugPrint(
'[TerminalPage] Initializing terminal ${widget.terminalId} for peer ${widget.id}');
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
peerId: widget.id,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
connToken: widget.connToken,
);
// Create terminal model with specific terminal ID
_terminalModel = TerminalModel(_ffi, widget.terminalId);
debugPrint(
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
_terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0;
};
// Register this terminal model with FFI for event routing
_ffi.registerTerminalModel(widget.terminalId, _terminalModel);
// Web desktop users have full hardware keyboard access, so the on-screen
// terminal extra keys bar is unnecessary and disabled.
_showTerminalExtraKeys = !isWebDesktop &&
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
// Initialize terminal connection
WidgetsBinding.instance.addPostFrameCallback((_) {
_ffi.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
if (_showTerminalExtraKeys) {
_updateKeyboardHeight();
}
});
_ffi.ffiModel.updateEventListener(_ffi.sessionId, widget.id);
}
@override
void dispose() {
// Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose();
_keyboardDebounce?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
TerminalConnectionManager.releaseConnection(widget.id);
}
@override
void didChangeMetrics() {
super.didChangeMetrics();
_keyboardDebounce?.cancel();
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
setState(() {
_sysKeyboardHeight = bottomInset;
});
});
}
void _updateKeyboardHeight() {
if (_keyboardKey.currentContext != null) {
final renderBox = _keyboardKey.currentContext!.findRenderObject() as RenderBox;
_keyboardHeight = renderBox.size.height;
}
}
EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) {
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
}
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
final rows = (realHeight / _cellHeight!).floor();
final extraSpace = realHeight - rows * _cellHeight!;
final topBottom = max(0.0, extraSpace / 2.0);
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
}
@override
Widget build(BuildContext context) {
super.build(context);
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, _ffi);
return false; // Prevent default back behavior
},
child: buildBody(),
);
}
Widget buildBody() {
final scaffold = Scaffold(
resizeToAvoidBottomInset: false, // Disable automatic layout adjustment; manually control UI updates to prevent flickering when the keyboard shows/hides
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Stack(
children: [
Positioned.fill(
child: SafeArea(
top: true,
child: LayoutBuilder(
builder: (context, constraints) {
final heightPx = constraints.maxHeight;
return TerminalView(
_terminalModel.terminal,
controller: _terminalModel.terminalController,
autofocus: true,
textStyle: _getTerminalStyle(),
backgroundOpacity: 0.7,
// The following comment is from xterm.dart source code:
// Workaround to detect delete key for platforms and IMEs that do not
// emit a hardware delete event. Preferred on mobile platforms. [false] by
// default.
//
// Android works fine without this workaround.
deleteDetection: isIOS,
padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async {
final selection = _terminalModel.terminalController.selection;
if (selection != null) {
final text = _terminalModel.terminal.buffer.getText(selection);
_terminalModel.terminalController.clearSelection();
await Clipboard.setData(ClipboardData(text: text));
} else {
final data = await Clipboard.getData('text/plain');
final text = data?.text;
if (text != null) {
_terminalModel.terminal.paste(text);
}
}
},
);
},
),
),
),
if (_showTerminalExtraKeys) _buildFloatingKeyboard(),
// iOS-style circular close button in top-right corner
if (isIOS) _buildCloseButton(),
],
),
);
// Add iOS edge swipe gesture to exit (similar to Android back button)
if (isIOS) {
return LayoutBuilder(
builder: (context, constraints) {
final screenWidth = constraints.maxWidth;
// Base thresholds on screen width but clamp to reasonable logical pixel ranges
// Edge detection region: ~10% of width, clamped between 20 and 80 logical pixels
final edgeThreshold = (screenWidth * 0.1).clamp(20.0, 80.0);
// Required horizontal movement: ~25% of width, clamped between 80 and 300 logical pixels
final swipeThreshold = (screenWidth * 0.25).clamp(80.0, 300.0);
return RawGestureDetector(
behavior: HitTestBehavior.translucent,
gestures: <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this,
// Only respond to touch input, exclude mouse/trackpad
supportedDevices: kTouchBasedDeviceKinds,
),
(HorizontalDragGestureRecognizer instance) {
instance
// Capture initial touch-down position (before touch slop)
..onDown = (details) {
_swipeStartX = details.localPosition.dx;
_swipeCurrentX = details.localPosition.dx;
}
..onUpdate = (details) {
_swipeCurrentX = details.localPosition.dx;
}
..onEnd = (details) {
// Check if swipe started from left edge and moved right
if (_swipeStartX < edgeThreshold && (_swipeCurrentX - _swipeStartX) > swipeThreshold) {
clientClose(sessionId, _ffi);
}
_swipeStartX = 0;
_swipeCurrentX = 0;
}
..onCancel = () {
_swipeStartX = 0;
_swipeCurrentX = 0;
};
},
),
},
child: scaffold,
);
},
);
}
return scaffold;
}
Widget _buildCloseButton() {
return Positioned(
top: 0,
right: 0,
child: SafeArea(
minimum: const EdgeInsets.only(
top: 16, // iOS standard margin
right: 16, // iOS standard margin
),
child: Semantics(
button: true,
label: translate('Close'),
child: Container(
width: 44, // iOS standard tap target size
height: 44,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5), // Half transparency
shape: BoxShape.circle,
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
clipBehavior: Clip.antiAlias,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
clientClose(sessionId, _ffi);
},
child: Tooltip(
message: translate('Close'),
child: const Icon(
Icons.chevron_left, // iOS-style back arrow
color: Colors.white,
size: 28,
),
),
),
),
),
),
),
);
}
Widget _buildFloatingKeyboard() {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: 0,
right: 0,
bottom: _sysKeyboardHeight,
child: Container(
key: _keyboardKey,
color: Theme.of(context).scaffoldBackgroundColor,
padding: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Esc'),
const SizedBox(width: 2),
_buildKeyButton('/'),
const SizedBox(width: 2),
_buildKeyButton('|'),
const SizedBox(width: 2),
_buildKeyButton('Home'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('End'),
const SizedBox(width: 2),
_buildKeyButton('PgUp'),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildKeyButton('Tab'),
const SizedBox(width: 2),
_buildKeyButton('Ctrl+C'),
const SizedBox(width: 2),
_buildKeyButton('~'),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton(''),
const SizedBox(width: 2),
_buildKeyButton('PgDn'),
],
),
],
),
),
);
}
Widget _buildKeyButton(String label) {
return ElevatedButton(
onPressed: () {
_sendKeyToTerminal(label);
},
child: Text(label),
style: ElevatedButton.styleFrom(
minimumSize: const Size(48, 32),
padding: EdgeInsets.zero,
textStyle: const TextStyle(fontSize: 12),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
}
void _sendKeyToTerminal(String key) {
String? send;
switch (key) {
case 'Esc':
send = '\x1B';
break;
case 'Tab':
send = '\t';
break;
case 'Ctrl+C':
send = '\x03';
break;
case '':
send = '\x1B[A';
break;
case '':
send = '\x1B[B';
break;
case '':
send = '\x1B[C';
break;
case '':
send = '\x1B[D';
break;
case 'Home':
send = '\x1B[H';
break;
case 'End':
send = '\x1B[F';
break;
case 'PgUp':
send = '\x1B[5~';
break;
case 'PgDn':
send = '\x1B[6~';
break;
default:
send = key;
break;
}
if (send != null) {
_terminalModel.sendVirtualKey(send);
}
}
// https://github.com/TerminalStudio/xterm.dart/issues/42#issuecomment-877495472
// https://github.com/TerminalStudio/xterm.dart/issues/198#issuecomment-2526548458
TerminalStyle _getTerminalStyle() {
return isWeb
? TerminalStyle(
fontFamily: _robotoMonoFontFamily,
fontSize: 14,
)
: const TerminalStyle();
}
@override
bool get wantKeepAlive => true;
}
@@ -0,0 +1,727 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/common/shared_state.dart';
import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/models/chat_model.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:provider/provider.dart';
import '../../common.dart';
import '../../common/widgets/overlay.dart';
import '../../common/widgets/dialog.dart';
import '../../common/widgets/remote_input.dart';
import '../../models/input_model.dart';
import '../../models/model.dart';
import '../../models/platform_model.dart';
import '../../utils/image.dart';
final initText = '1' * 1024;
// Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
// When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
// https://github.com/flutter/flutter/issues/159384
// https://github.com/flutter/flutter/issues/159383
void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
if (isAndroid) {
if (isKeyboardVisible != true) {
// `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
gFFI.invokeMethod("enable_soft_keyboard", false);
}
}
}
class ViewCameraPage extends StatefulWidget {
ViewCameraPage(
{Key? key,
required this.id,
this.password,
this.isSharedPassword,
this.forceRelay})
: super(key: key);
final String id;
final String? password;
final bool? isSharedPassword;
final bool? forceRelay;
@override
State<ViewCameraPage> createState() => _ViewCameraPageState(id);
}
class _ViewCameraPageState extends State<ViewCameraPage>
with WidgetsBindingObserver {
Timer? _timer;
bool _showBar = !isWebDesktop;
bool _showGestureHelp = false;
Orientation? _currentOrientation;
double _viewInsetsBottom = 0;
final _uniqueKey = UniqueKey();
Timer? _timerDidChangeMetrics;
final _blockableOverlayState = BlockableOverlayState();
final keyboardVisibilityController = KeyboardVisibilityController();
final FocusNode _mobileFocusNode = FocusNode();
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId;
final TextEditingController _textController =
TextEditingController(text: initText);
_ViewCameraPageState(String id) {
initSharedStates(id);
gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
gFFI.dialogManager.loadMobileActionsOverlayVisible();
}
@override
void initState() {
super.initState();
gFFI.ffiModel.updateEventListener(sessionId, widget.id);
gFFI.start(
widget.id,
isViewCamera: true,
password: widget.password,
isSharedPassword: widget.isSharedPassword,
forceRelay: widget.forceRelay,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
gFFI.dialogManager
.showLoading(translate('Connecting...'), onCancel: closeConnection);
});
WakelockManager.enable(_uniqueKey);
_physicalFocusNode.requestFocus();
gFFI.inputModel.listenToMouse(true);
gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
gFFI.chatModel
.changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
_blockableOverlayState.applyFfi(gFFI);
gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
gFFI.recordingModel
.updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
if (gFFI.recordingModel.start) {
showToast(translate('Automatically record outgoing sessions'));
}
_disableAndroidSoftKeyboard(
isKeyboardVisible: keyboardVisibilityController.isVisible);
});
WidgetsBinding.instance.addObserver(this);
}
@override
Future<void> dispose() async {
WidgetsBinding.instance.removeObserver(this);
// https://github.com/flutter/flutter/issues/64935
super.dispose();
gFFI.dialogManager.hideMobileActionsOverlay(store: false);
gFFI.inputModel.listenToMouse(false);
gFFI.imageModel.disposeImage();
gFFI.cursorModel.disposeImages();
await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose();
_physicalFocusNode.dispose();
await gFFI.close();
_timer?.cancel();
_timerDidChangeMetrics?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
WakelockManager.disable(_uniqueKey);
removeSharedStates(widget.id);
// `on_voice_call_closed` should be called when the connection is ended.
// The inner logic of `on_voice_call_closed` will check if the voice call is active.
// Only one client is considered here for now.
gFFI.chatModel.onVoiceCallClosed("End connetion");
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {}
@override
void didChangeMetrics() {
// If the soft keyboard is visible and the canvas has been changed(panned or scaled)
// Don't try reset the view style and focus the cursor.
if (gFFI.cursorModel.lastKeyboardIsVisible &&
gFFI.canvasModel.isMobileCanvasChanged) {
return;
}
final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
_timerDidChangeMetrics?.cancel();
_timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
// We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
if (newBottom != _viewInsetsBottom) {
gFFI.canvasModel.mobileFocusCanvasCursor();
_viewInsetsBottom = newBottom;
}
});
}
// to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
// But I don't know why and how to fix it.
Widget emptyOverlay(Color bgColor) => BlockableOverlay(
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
/// see override build() in [BlockableOverlay]
state: _blockableOverlayState,
underlying: Container(
color: bgColor,
),
);
Widget _bottomWidget() => (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
? getBottomAppBar()
: Offstage());
@override
Widget build(BuildContext context) {
final keyboardIsVisible =
keyboardVisibilityController.isVisible && _showEdit;
final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
return WillPopScope(
onWillPop: () async {
clientClose(sessionId, gFFI);
return false;
},
child: Scaffold(
// workaround for https://github.com/rustdesk/rustdesk/issues/3131
floatingActionButtonLocation: keyboardIsVisible
? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
: null,
floatingActionButton: !showActionButton
? null
: FloatingActionButton(
mini: !keyboardIsVisible,
child: Icon(
(keyboardIsVisible || _showGestureHelp)
? Icons.expand_more
: Icons.expand_less,
color: Colors.white,
),
backgroundColor: MyTheme.accent,
onPressed: () {
setState(() {
if (keyboardIsVisible) {
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
} else if (_showGestureHelp) {
_showGestureHelp = false;
} else {
_showBar = !_showBar;
}
});
}),
bottomNavigationBar: Obx(() => Stack(
alignment: Alignment.bottomCenter,
children: [
gFFI.ffiModel.pi.isSet.isTrue &&
gFFI.ffiModel.waitForFirstImage.isTrue
? emptyOverlay(MyTheme.canvasColor)
: () {
gFFI.ffiModel.tryShowAndroidActionsOverlay();
return Offstage();
}(),
_bottomWidget(),
gFFI.ffiModel.pi.isSet.isFalse
? emptyOverlay(MyTheme.canvasColor)
: Offstage(),
],
)),
body: Obx(
() => getRawPointerAndKeyBody(Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return Container(
color: kColorCanvas,
child: SafeArea(
child: OrientationBuilder(builder: (ctx, orientation) {
if (_currentOrientation != orientation) {
Timer(const Duration(milliseconds: 200), () {
gFFI.dialogManager
.resetMobileActionsOverlay(ffi: gFFI);
_currentOrientation = orientation;
gFFI.canvasModel.updateViewStyle();
});
}
return Container(
color: MyTheme.canvasColor,
child: RawTouchGestureDetectorRegion(
child: getBodyForMobile(),
ffi: gFFI,
isCamera: true,
),
);
}),
),
);
})
],
)),
)),
);
}
Widget getRawPointerAndKeyBody(Widget child) {
return CameraRawPointerMouseRegion(
inputModel: inputModel,
// Disable RawKeyFocusScope before the connecting is established.
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
child: gFFI.ffiModel.pi.isSet.isTrue
? RawKeyFocusScope(
focusNode: _physicalFocusNode,
inputModel: inputModel,
child: child)
: child,
);
}
Widget getBottomAppBar() {
return BottomAppBar(
elevation: 10,
color: MyTheme.accent,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
IconButton(
color: Colors.white,
icon: Icon(Icons.clear),
onPressed: () {
clientClose(sessionId, gFFI);
},
),
IconButton(
color: Colors.white,
icon: Icon(Icons.tv),
onPressed: () {
setState(() => _showEdit = false);
showOptions(context, widget.id, gFFI.dialogManager);
},
)
] +
(isWeb
? []
: <Widget>[
futureBuilder(
future: gFFI.invokeMethod(
"get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
hasData: (isSupportVoiceCall) => IconButton(
color: Colors.white,
icon: isAndroid && isSupportVoiceCall
? SvgPicture.asset('assets/chat.svg',
colorFilter: ColorFilter.mode(
Colors.white, BlendMode.srcIn))
: Icon(Icons.message),
onPressed: () =>
isAndroid && isSupportVoiceCall
? showChatOptions(widget.id)
: onPressedTextChat(widget.id),
))
]) +
[
IconButton(
color: Colors.white,
icon: Icon(Icons.more_vert),
onPressed: () {
setState(() => _showEdit = false);
showActions(widget.id);
},
),
]),
Obx(() => IconButton(
color: Colors.white,
icon: Icon(Icons.expand_more),
onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
? null
: () {
setState(() => _showBar = !_showBar);
},
)),
],
),
);
}
Widget getBodyForMobile() {
return Container(
color: MyTheme.canvasColor,
child: Stack(children: () {
final paints = [
ImagePaint(),
Positioned(
top: 10,
right: 10,
child: QualityMonitor(gFFI.qualityMonitorModel),
),
SizedBox(
width: 0,
height: 0,
child: !_showEdit
? Container()
: TextFormField(
textInputAction: TextInputAction.newline,
autocorrect: false,
// Flutter 3.16.9 Android.
// `enableSuggestions` causes secure keyboard to be shown.
// https://github.com/flutter/flutter/issues/139143
// https://github.com/flutter/flutter/issues/146540
// enableSuggestions: false,
autofocus: true,
focusNode: _mobileFocusNode,
maxLines: null,
controller: _textController,
// trick way to make backspace work always
keyboardType: TextInputType.multiline,
// `onChanged` may be called depending on the input method if this widget is wrapped in
// `Focus(onKeyEvent: ..., child: ...)`
// For `Backspace` button in the soft keyboard:
// en/fr input method:
// 1. The button will not trigger `onKeyEvent` if the text field is not empty.
// 2. The button will trigger `onKeyEvent` if the text field is empty.
// ko/zh/ja input method: the button will trigger `onKeyEvent`
// and the event will not popup if `KeyEventResult.handled` is returned.
onChanged: null,
).workaroundFreezeLinuxMint(),
),
];
return paints;
}()));
}
Widget getBodyForDesktopWithListener() {
var paints = <Widget>[ImagePaint()];
return Container(
color: MyTheme.canvasColor, child: Stack(children: paints));
}
List<TTextMenu> _getMobileActionMenus() {
if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
!gFFI.ffiModel.keyboard) {
return [];
}
final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
if (!enabled) return [];
return [
TTextMenu(
child: Text(translate('Back')),
onPressed: () => gFFI.inputModel.onMobileBack(),
),
TTextMenu(
child: Text(translate('Home')),
onPressed: () => gFFI.inputModel.onMobileHome(),
),
TTextMenu(
child: Text(translate('Apps')),
onPressed: () => gFFI.inputModel.onMobileApps(),
),
TTextMenu(
child: Text(translate('Volume up')),
onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
),
TTextMenu(
child: Text(translate('Volume down')),
onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
),
TTextMenu(
child: Text(translate('Power')),
onPressed: () => gFFI.inputModel.onMobilePower(),
),
];
}
void showActions(String id) async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
final mobileActionMenus = _getMobileActionMenus();
final menus = toolbarControls(context, id, gFFI);
final List<PopupMenuEntry<int>> more = [
...mobileActionMenus
.asMap()
.entries
.map((e) =>
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(),
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
...menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(
child: e.value.getChild(),
value: e.key + mobileActionMenus.length))
.toList(),
];
() async {
var index = await showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: more,
elevation: 8,
);
if (index != null) {
if (index < mobileActionMenus.length) {
mobileActionMenus[index].onPressed?.call();
} else if (index < mobileActionMenus.length + more.length) {
menus[index - mobileActionMenus.length].onPressed?.call();
}
}
}();
}
onPressedTextChat(String id) {
gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
gFFI.chatModel.toggleChatOverlay();
}
showChatOptions(String id) async {
onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
makeTextMenu(String label, Widget icon, VoidCallback onPressed,
{TextStyle? labelStyle}) =>
TTextMenu(
child: Text(translate(label), style: labelStyle),
trailingIcon: Transform.scale(
scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
child: IgnorePointer(
child: IconButton(
onPressed: null,
icon: icon,
),
),
),
onPressed: onPressed,
);
final isInVoice = [
VoiceCallStatus.waitingForResponse,
VoiceCallStatus.connected
].contains(gFFI.chatModel.voiceCallStatus.value);
final menus = [
makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
() => onPressedTextChat(widget.id)),
isInVoice
? makeTextMenu(
'End voice call',
SvgPicture.asset(
'assets/call_wait.svg',
colorFilter:
ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
),
onPressEndVoiceCall,
labelStyle: TextStyle(color: Colors.redAccent))
: makeTextMenu(
'Voice call',
SvgPicture.asset(
'assets/call_wait.svg',
colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
),
onPressVoiceCall),
];
final menuItems = menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList();
Future.delayed(Duration.zero, () async {
final size = MediaQuery.of(context).size;
final x = 120.0;
final y = size.height;
var index = await showMenu(
context: context,
position: RelativeRect.fromLTRB(x, y, x, y),
items: menuItems,
elevation: 8,
);
if (index != null && index < menus.length) {
menus[index].onPressed?.call();
}
});
}
}
class ImagePaint extends StatelessWidget {
@override
Widget build(BuildContext context) {
final m = Provider.of<ImageModel>(context);
final c = Provider.of<CanvasModel>(context);
var s = c.scale;
final adjust = c.getAdjustY();
return CustomPaint(
painter: ImagePainter(
image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
);
}
}
void showOptions(
BuildContext context, String id, OverlayDialogManager dialogManager) async {
var displays = <Widget>[];
final pi = gFFI.ffiModel.pi;
final image = gFFI.ffiModel.getConnectionImageText();
if (image != null) {
displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
}
if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
final cur = pi.currentDisplay;
final children = <Widget>[];
final isDarkTheme = MyTheme.currentThemeMode() == ThemeMode.dark;
final numColorSelected = Colors.white;
final numColorUnselected = isDarkTheme ? Colors.grey : Colors.black87;
// We can't use `Theme.of(context).primaryColor` here, the color is:
// - light theme: 0xff2196f3 (Colors.blue)
// - dark theme: 0xff212121 (the canvas color?)
final numBgSelected =
Theme.of(context).colorScheme.primary.withOpacity(0.6);
for (var i = 0; i < pi.displays.length; ++i) {
children.add(InkWell(
onTap: () {
if (i == cur) return;
openMonitorInTheSameTab(i, gFFI, pi);
gFFI.dialogManager.dismissAll();
},
child: Ink(
width: 40,
height: 40,
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).hintColor),
borderRadius: BorderRadius.circular(2),
color: i == cur ? numBgSelected : null),
child: Center(
child: Text((i + 1).toString(),
style: TextStyle(
color:
i == cur ? numColorSelected : numColorUnselected,
fontWeight: FontWeight.bold))))));
}
displays.add(Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 8,
children: children,
)));
}
if (displays.isNotEmpty) {
displays.add(const Divider(color: MyTheme.border));
}
List<TRadioMenu<String>> viewStyleRadios =
await toolbarViewStyle(context, id, gFFI);
List<TRadioMenu<String>> imageQualityRadios =
await toolbarImageQuality(context, id, gFFI);
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI);
dialogManager.show((setState, close, context) {
var viewStyle =
(viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
var imageQuality =
(imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
.obs;
var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
final radios = [
for (var e in viewStyleRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
viewStyle.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) viewStyle.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in imageQualityRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
imageQuality.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) imageQuality.value = v;
}
: null)),
const Divider(color: MyTheme.border),
for (var e in codecRadios)
Obx(() => getRadio<String>(
e.child,
e.value,
codec.value,
e.onChanged != null
? (v) {
e.onChanged?.call(v);
if (v != null) codec.value = v;
}
: null)),
if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
];
final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
final displayTogglesList = displayToggles
.asMap()
.entries
.map((e) => Obx(() => CheckboxListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
value: rxToggleValues[e.key].value,
onChanged: e.value.onChanged != null
? (v) {
e.value.onChanged?.call(v);
if (v != null) rxToggleValues[e.key].value = v;
}
: null,
title: e.value.child)))
.toList();
final toggles = [
...displayTogglesList,
];
var popupDialogMenus = List<Widget>.empty(growable: true);
if (popupDialogMenus.isNotEmpty) {
popupDialogMenus.add(const Divider(color: MyTheme.border));
}
return CustomAlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: displays + radios + popupDialogMenus + toggles),
);
}, clickMaskDismiss: true, backDismiss: true).then((value) {
_disableAndroidSoftKeyboard();
});
}
class FABLocation extends FloatingActionButtonLocation {
FloatingActionButtonLocation location;
double offsetX;
double offsetY;
FABLocation(this.location, this.offsetX, this.offsetY);
@override
Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
final offset = location.getOffset(scaffoldGeometry);
return Offset(offset.dx + offsetX, offset.dy + offsetY);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
class MobileCustomScaleControls extends StatefulWidget {
final FFI ffi;
final ValueChanged<int>? onChanged;
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
@override
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
}
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
@override
FFI get ffi => widget.ffi;
@override
ValueChanged<int>? get onScaleChanged => widget.onChanged;
@override
Widget build(BuildContext context) {
// Smaller button size for mobile
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
final sliderControl = Slider(
value: scalePos,
min: 0.0,
max: 1.0,
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
label: '$scaleValue%',
onChanged: onSliderChanged,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${translate("Scale custom")}: $scaleValue%',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.remove),
tooltip: translate('Decrease'),
onPressed: () => nudgeScale(-1),
),
Expanded(child: sliderControl),
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.add),
tooltip: translate('Increase'),
onPressed: () => nudgeScale(1),
),
],
),
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}
+234
View File
@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
void _showSuccess() {
showToast(translate("Successful"));
}
void setTemporaryPasswordLengthDialog(
OverlayDialogManager dialogManager) async {
List<String> lengths = ['6', '8', '10'];
String length = await bind.mainGetOption(key: "temporary-password-length");
var index = lengths.indexOf(length);
if (index < 0) index = 0;
length = lengths[index];
dialogManager.show((setState, close, context) {
setLength(newValue) {
final oldValue = length;
if (oldValue == newValue) return;
setState(() {
length = newValue;
});
bind.mainSetOption(key: "temporary-password-length", value: newValue);
bind.mainUpdateTemporaryPassword();
Future.delayed(Duration(milliseconds: 200), () {
close();
_showSuccess();
});
}
return CustomAlertDialog(
title: Text(translate("Set one-time password length")),
content: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: lengths
.map(
(value) => Row(
children: [
Text(value),
Radio(
value: value, groupValue: length, onChanged: setLength),
],
),
)
.toList()),
);
}, backDismiss: true, clickMaskDismiss: true);
}
void showServerSettings(OverlayDialogManager dialogManager,
void Function(VoidCallback) setState) async {
Map<String, dynamic> options = {};
try {
options = jsonDecode(await bind.mainGetOptions());
} catch (e) {
print("Invalid server config: $e");
}
showServerSettingsWithValue(
ServerConfig.fromOptions(options), dialogManager, setState);
}
void showServerSettingsWithValue(
ServerConfig serverConfig,
OverlayDialogManager dialogManager,
void Function(VoidCallback)? upSetState) async {
var isInProgress = false;
final idCtrl = TextEditingController(text: serverConfig.idServer);
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
final apiCtrl = TextEditingController(text: serverConfig.apiServer);
final keyCtrl = TextEditingController(text: serverConfig.key);
RxString idServerMsg = ''.obs;
RxString relayServerMsg = ''.obs;
RxString apiServerMsg = ''.obs;
final controllers = [idCtrl, relayCtrl, apiCtrl, keyCtrl];
final errMsgs = [
idServerMsg,
relayServerMsg,
apiServerMsg,
];
dialogManager.show((setState, close, context) {
Future<bool> submit() async {
setState(() {
isInProgress = true;
});
bool ret = await setServerConfig(
null,
errMsgs,
ServerConfig(
idServer: idCtrl.text.trim(),
relayServer: relayCtrl.text.trim(),
apiServer: apiCtrl.text.trim(),
key: keyCtrl.text.trim()));
setState(() {
isInProgress = false;
});
return ret;
}
Widget buildField(
String label, TextEditingController controller, String errorMsg,
{String? Function(String?)? validator, bool autofocus = false}) {
if (isDesktop || isWeb) {
return Row(
children: [
SizedBox(
width: 120,
child: Text(label),
),
SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
errorText: errorMsg.isEmpty ? null : errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
),
validator: validator,
autofocus: autofocus,
).workaroundFreezeLinuxMint(),
),
],
);
}
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
errorText: errorMsg.isEmpty ? null : errorMsg,
),
validator: validator,
).workaroundFreezeLinuxMint();
}
return CustomAlertDialog(
title: Row(
children: [
Expanded(child: Text(translate('ID/Relay Server'))),
...ServerConfigImportExportWidgets(controllers, errMsgs),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Form(
child: Obx(() => Column(
mainAxisSize: MainAxisSize.min,
children: [
buildField(translate('ID Server'), idCtrl, idServerMsg.value,
autofocus: true),
SizedBox(height: 8),
if (!isIOS && !isWeb) ...[
buildField(translate('Relay Server'), relayCtrl,
relayServerMsg.value),
SizedBox(height: 8),
],
buildField(
translate('API Server'),
apiCtrl,
apiServerMsg.value,
validator: (v) {
if (v != null && v.isNotEmpty) {
if (!(v.startsWith('http://') ||
v.startsWith("https://"))) {
return translate("invalid_http");
}
}
return null;
},
),
SizedBox(height: 8),
buildField('Key', keyCtrl, ''),
if (isInProgress)
Padding(
padding: EdgeInsets.only(top: 8),
child: LinearProgressIndicator(),
),
],
)),
),
),
actions: [
dialogButton('Cancel', onPressed: () {
close();
}, isOutline: true),
dialogButton(
'OK',
onPressed: () async {
if (await submit()) {
close();
showToast(translate('Successful'));
upSetState?.call(() {});
} else {
showToast(translate('Failed'));
}
},
),
],
);
});
}
void setPrivacyModeDialog(
OverlayDialogManager dialogManager,
List<TToggleMenu> privacyModeList,
RxString privacyModeState,
) async {
dialogManager.dismissAll();
dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text(translate('Privacy mode')),
content: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: privacyModeList
.map((value) => CheckboxListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
title: value.child,
value: value.value,
onChanged: value.onChanged,
))
.toList()),
);
}, backDismiss: true, clickMaskDismiss: true);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,905 @@
// These floating mouse widgets are used to simulate a physical mouse
// when "mobile" -> "desktop" in mouse mode.
// This file does not contain whole mouse widgets, it only contains
// parts that help to control, such as wheel scroll and wheel button.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
// Used for the wheel button and wheel scroll widgets
const double _kSpaceToHorizontalEdge = 25;
const double _wheelWidth = 50;
const double _wheelHeight = 162;
// Used for the left/right button widgets
const double _kSpaceToVerticalEdge = 15;
const double _kSpaceBetweenLeftRightButtons = 40;
const double _kLeftRightButtonWidth = 55;
const double _kLeftRightButtonHeight = 40;
const double _kBorderWidth = 1;
final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7);
final Color _kDefaultColor = Colors.black.withOpacity(0.4);
final Color _kTapDownColor = Colors.blue.withOpacity(0.7);
final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9);
const int _kInputTimerIntervalMillis = 100;
class FloatingMouseWidgets extends StatefulWidget {
final FFI ffi;
const FloatingMouseWidgets({
super.key,
required this.ffi,
});
@override
State<FloatingMouseWidgets> createState() => _FloatingMouseWidgetsState();
}
class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
InputModel get _inputModel => widget.ffi.inputModel;
CursorModel get _cursorModel => widget.ffi.cursorModel;
late final VirtualMouseMode _virtualMouseMode;
@override
void initState() {
super.initState();
_virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode;
_virtualMouseMode.addListener(_onVirtualMouseModeChanged);
_cursorModel.blockEvents = false;
isSpecialHoldDragActive = false;
}
void _onVirtualMouseModeChanged() {
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
_virtualMouseMode.removeListener(_onVirtualMouseModeChanged);
super.dispose();
_cursorModel.blockEvents = false;
isSpecialHoldDragActive = false;
}
@override
Widget build(BuildContext context) {
final virtualMouseMode = _virtualMouseMode;
if (!virtualMouseMode.showVirtualMouse) {
return const Offstage();
}
return Stack(
children: [
FloatingWheel(
inputModel: _inputModel,
cursorModel: _cursorModel,
),
if (virtualMouseMode.showVirtualJoystick)
VirtualJoystick(
cursorModel: _cursorModel,
inputModel: _inputModel,
),
FloatingLeftRightButton(
isLeft: true,
inputModel: _inputModel,
cursorModel: _cursorModel,
),
FloatingLeftRightButton(
isLeft: false,
inputModel: _inputModel,
cursorModel: _cursorModel,
),
],
);
}
}
class FloatingWheel extends StatefulWidget {
final InputModel inputModel;
final CursorModel cursorModel;
const FloatingWheel(
{super.key, required this.inputModel, required this.cursorModel});
@override
State<FloatingWheel> createState() => _FloatingWheelState();
}
class _FloatingWheelState extends State<FloatingWheel> {
Offset _position = Offset.zero;
bool _isInitialized = false;
Rect? _lastBlockedRect;
bool _isUpDown = false;
bool _isMidDown = false;
bool _isDownDown = false;
Orientation? _previousOrientation;
Timer? _scrollTimer;
InputModel get _inputModel => widget.inputModel;
CursorModel get _cursorModel => widget.cursorModel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_resetPosition();
});
}
void _resetPosition() {
final size = MediaQuery.of(context).size;
setState(() {
_position = Offset(
size.width - _wheelWidth - _kSpaceToHorizontalEdge,
(size.height - _wheelHeight) / 2,
);
_isInitialized = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
void _updateBlockedRect() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
final newRect =
Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight);
_cursorModel.addBlockedRect(newRect);
_lastBlockedRect = newRect;
}
@override
void dispose() {
_scrollTimer?.cancel();
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentOrientation = MediaQuery.of(context).orientation;
if (_previousOrientation != null &&
_previousOrientation != currentOrientation) {
_resetPosition();
}
_previousOrientation = currentOrientation;
}
Widget _buildUpDownButton(
void Function(PointerDownEvent) onPointerDown,
void Function(PointerUpEvent) onPointerUp,
void Function(PointerCancelEvent) onPointerCancel,
bool Function() flagGetter,
BorderRadiusGeometry borderRadius,
IconData iconData) {
return Listener(
onPointerDown: onPointerDown,
onPointerUp: onPointerUp,
onPointerCancel: onPointerCancel,
child: Container(
width: _wheelWidth,
height: 55,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.all(
color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor,
width: 1),
borderRadius: borderRadius,
),
child: Icon(iconData, color: _kDefaultBorderColor, size: 32),
),
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx,
top: _position.dy,
child: _buildWidget(context),
);
}
Widget _buildWidget(BuildContext context) {
return Container(
width: _wheelWidth,
height: _wheelHeight,
child: Column(
children: [
_buildUpDownButton(
(event) {
setState(() {
_isUpDown = true;
});
_startScrollTimer(1);
},
(event) {
setState(() {
_isUpDown = false;
});
_stopScrollTimer();
},
(event) {
setState(() {
_isUpDown = false;
});
_stopScrollTimer();
},
() => _isUpDown,
BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)),
Icons.keyboard_arrow_up,
),
Listener(
onPointerDown: (event) {
setState(() {
_isMidDown = true;
});
_inputModel.tapDown(MouseButtons.wheel);
},
onPointerUp: (event) {
setState(() {
_isMidDown = false;
});
_inputModel.tapUp(MouseButtons.wheel);
},
onPointerCancel: (event) {
setState(() {
_isMidDown = false;
});
_inputModel.tapUp(MouseButtons.wheel);
},
child: Container(
width: _wheelWidth,
height: 52,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.symmetric(
vertical: BorderSide(
color:
_isMidDown ? _kTapDownColor : _kDefaultBorderColor,
width: _kBorderWidth)),
),
child: Center(
child: Container(
width: _wheelWidth - 10,
height: _wheelWidth - 10,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 2,
color: _kDefaultBorderColor,
),
SizedBox(height: 6),
Container(
width: 24,
height: 2,
color: _kDefaultBorderColor,
),
SizedBox(height: 6),
Container(
width: 18,
height: 2,
color: _kDefaultBorderColor,
),
],
),
),
),
),
),
),
_buildUpDownButton(
(event) {
setState(() {
_isDownDown = true;
});
_startScrollTimer(-1);
},
(event) {
setState(() {
_isDownDown = false;
});
_stopScrollTimer();
},
(event) {
setState(() {
_isDownDown = false;
});
_stopScrollTimer();
},
() => _isDownDown,
BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)),
Icons.keyboard_arrow_down,
),
],
),
);
}
void _startScrollTimer(int direction) {
_scrollTimer?.cancel();
_inputModel.scroll(direction);
_scrollTimer = Timer.periodic(
Duration(milliseconds: _kInputTimerIntervalMillis), (timer) {
_inputModel.scroll(direction);
});
}
void _stopScrollTimer() {
_scrollTimer?.cancel();
_scrollTimer = null;
}
}
class FloatingLeftRightButton extends StatefulWidget {
final bool isLeft;
final InputModel inputModel;
final CursorModel cursorModel;
const FloatingLeftRightButton(
{super.key,
required this.isLeft,
required this.inputModel,
required this.cursorModel});
@override
State<FloatingLeftRightButton> createState() =>
_FloatingLeftRightButtonState();
}
class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
Offset _position = Offset.zero;
bool _isInitialized = false;
bool _isDown = false;
Rect? _lastBlockedRect;
Orientation? _previousOrientation;
Offset _preSavedPos = Offset.zero;
// Gesture ambiguity resolution
Timer? _tapDownTimer;
final Duration _pressTimeout = const Duration(milliseconds: 200);
bool _isDragging = false;
bool get _isLeft => widget.isLeft;
InputModel get _inputModel => widget.inputModel;
CursorModel get _cursorModel => widget.cursorModel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentOrientation = MediaQuery.of(context).orientation;
_previousOrientation = currentOrientation;
_resetPosition(currentOrientation);
});
}
@override
void dispose() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
_tapDownTimer?.cancel();
_trySavePosition();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentOrientation = MediaQuery.of(context).orientation;
if (_previousOrientation == null ||
_previousOrientation != currentOrientation) {
_resetPosition(currentOrientation);
}
_previousOrientation = currentOrientation;
}
double _getOffsetX(double w) {
if (_isLeft) {
return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) *
0.5;
} else {
return (w + _kSpaceBetweenLeftRightButtons) * 0.5;
}
}
String _getPositionKey(Orientation ori) {
final strLeftRight = _isLeft ? 'l' : 'r';
final strOri = ori == Orientation.landscape ? 'l' : 'p';
return '$strLeftRight$strOri-mouse-btn-pos';
}
static Offset? _loadPositionFromString(String s) {
if (s.isEmpty) {
return null;
}
try {
final m = jsonDecode(s);
return Offset(m['x'], m['y']);
} catch (e) {
debugPrintStack(label: 'Failed to load position "$s" $e');
return null;
}
}
void _trySavePosition() {
if (_previousOrientation == null) return;
if (((_position - _preSavedPos)).distanceSquared < 0.1) return;
final pos = jsonEncode({
'x': _position.dx,
'y': _position.dy,
});
bind.setLocalFlutterOption(
k: _getPositionKey(_previousOrientation!), v: pos);
_preSavedPos = _position;
}
void _restorePosition(Orientation ori) {
final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori));
final pos = _loadPositionFromString(ps);
if (pos == null) {
final size = MediaQuery.of(context).size;
_position = Offset(_getOffsetX(size.width),
size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight);
} else {
_position = pos;
_preSavedPos = pos;
}
}
void _resetPosition(Orientation ori) {
setState(() {
_restorePosition(ori);
_isInitialized = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
void _updateBlockedRect() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
final newRect = Rect.fromLTWH(_position.dx, _position.dy,
_kLeftRightButtonWidth, _kLeftRightButtonHeight);
_cursorModel.addBlockedRect(newRect);
_lastBlockedRect = newRect;
}
void _onMoveUpdateDelta(Offset delta) {
final context = this.context;
final size = MediaQuery.of(context).size;
Offset newPosition = _position + delta;
double minX = _kSpaceToHorizontalEdge;
double minY = _kSpaceToVerticalEdge;
double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge;
double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge;
newPosition = Offset(
newPosition.dx.clamp(minX, maxX),
newPosition.dy.clamp(minY, maxY),
);
final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) &&
isDoubleEqual(newPosition.dy, _position.dy));
setState(() {
_position = newPosition;
});
if (isPositionChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
}
void _onBodyPointerMoveUpdate(PointerMoveEvent event) {
_cursorModel.blockEvents = true;
// If move, it's a drag, not a tap.
_isDragging = true;
// Cancel the timer to prevent it from being recognized as a tap/hold.
_tapDownTimer?.cancel();
_tapDownTimer = null;
_onMoveUpdateDelta(event.delta);
}
Widget _buildButtonIcon() {
final double w = _kLeftRightButtonWidth * 0.45;
final double h = _kLeftRightButtonHeight * 0.75;
final double borderRadius = w * 0.5;
final double quarterCircleRadius = borderRadius * 0.9;
return Stack(
children: [
Container(
width: w,
height: h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225),
color: Colors.white,
),
),
Positioned(
left: _isLeft ? quarterCircleRadius * 0.25 : null,
right: _isLeft ? null : quarterCircleRadius * 0.25,
top: quarterCircleRadius * 0.25,
child: CustomPaint(
size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2),
painter: _QuarterCirclePainter(
color: _kDefaultColor,
isLeft: _isLeft,
radius: quarterCircleRadius,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx,
top: _position.dy,
// We can't use the GestureDetector here, because `onTapDown` may be
// triggered sometimes when dragging.
child: Listener(
onPointerMove: _onBodyPointerMoveUpdate,
onPointerDown: (event) async {
_isDragging = false;
setState(() {
_isDown = true;
});
// Start a timer. If it fires, it's a hold.
_tapDownTimer?.cancel();
_tapDownTimer = Timer(_pressTimeout, () {
isSpecialHoldDragActive = true;
() async {
await _cursorModel.syncCursorPosition();
await _inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
}();
_tapDownTimer = null;
});
},
onPointerUp: (event) {
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
// If timer is active, it's a quick tap.
if (_tapDownTimer != null) {
_tapDownTimer!.cancel();
_tapDownTimer = null;
// Fire tap down and up quickly.
_inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right)
.then(
(_) => Future.delayed(const Duration(milliseconds: 50), () {
_inputModel.tapUp(
_isLeft ? MouseButtons.left : MouseButtons.right);
}));
} else {
// If it's not a quick tap, it could be a hold or drag.
// If it was a hold, isSpecialHoldDragActive is true.
if (isSpecialHoldDragActive) {
_inputModel
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
}
if (_isDragging) {
_trySavePosition();
}
isSpecialHoldDragActive = false;
},
onPointerCancel: (event) {
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
_tapDownTimer?.cancel();
_tapDownTimer = null;
if (isSpecialHoldDragActive) {
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
isSpecialHoldDragActive = false;
if (_isDragging) {
_trySavePosition();
}
},
child: Container(
width: _kLeftRightButtonWidth,
height: _kLeftRightButtonHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.all(
color: _isDown ? _kTapDownColor : _kDefaultBorderColor,
width: _kBorderWidth),
borderRadius: _isLeft
? BorderRadius.horizontal(
left: Radius.circular(_kLeftRightButtonHeight * 0.5))
: BorderRadius.horizontal(
right: Radius.circular(_kLeftRightButtonHeight * 0.5)),
),
child: _buildButtonIcon(),
),
),
);
}
}
class _QuarterCirclePainter extends CustomPainter {
final Color color;
final bool isLeft;
final double radius;
_QuarterCirclePainter(
{required this.color, required this.isLeft, required this.radius});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2);
if (isLeft) {
canvas.drawArc(rect, -pi, pi / 2, true, paint);
} else {
canvas.drawArc(rect, -pi / 2, pi / 2, true, paint);
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
// Virtual joystick can send either absolute movement (via updatePan)
// or relative movement (via sendMobileRelativeMouseMove) depending on the
// InputModel.relativeMouseMode setting.
class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel;
final InputModel inputModel;
const VirtualJoystick({
super.key,
required this.cursorModel,
required this.inputModel,
});
@override
State<VirtualJoystick> createState() => _VirtualJoystickState();
}
class _VirtualJoystickState extends State<VirtualJoystick> {
Offset _position = Offset.zero;
bool _isInitialized = false;
Offset _offset = Offset.zero;
final double _joystickRadius = 50.0;
final double _thumbRadius = 20.0;
final double _moveStep = 3.0;
final double _speed = 1.0;
/// Scale factor for relative mouse movement sensitivity.
/// Higher values result in faster cursor movement on the remote machine.
static const double _kRelativeMouseScale = 3.0;
// One-shot timer to detect a drag gesture
Timer? _dragStartTimer;
// Periodic timer for continuous movement
Timer? _continuousMoveTimer;
Size? _lastScreenSize;
bool _isPressed = false;
/// Check if relative mouse mode is enabled.
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
@override
void initState() {
super.initState();
widget.cursorModel.blockEvents = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
_lastScreenSize = MediaQuery.of(context).size;
_resetPosition();
});
}
@override
void dispose() {
_stopSendEventTimer();
widget.cursorModel.blockEvents = false;
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentScreenSize = MediaQuery.of(context).size;
if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) {
_resetPosition();
}
_lastScreenSize = currentScreenSize;
}
void _resetPosition() {
final size = MediaQuery.of(context).size;
setState(() {
_position = Offset(
_kSpaceToHorizontalEdge + _joystickRadius,
size.height * 0.5 + _joystickRadius * 1.5,
);
_isInitialized = true;
});
}
Offset _offsetToPanDelta(Offset offset) {
return Offset(
offset.dx / _joystickRadius,
offset.dy / _joystickRadius,
);
}
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
_dragStartTimer = null;
_continuousMoveTimer = null;
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx - _joystickRadius,
top: _position.dy - _joystickRadius,
child: GestureDetector(
onPanStart: (details) {
setState(() {
_isPressed = true;
});
widget.cursorModel.blockEvents = true;
_updateOffset(details.localPosition);
// 1. Send a single, small pan event immediately for responsiveness.
// The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) {
_sendMovement(initialDelta);
}
// 2. Start a one-shot timer to check if the user is holding for a drag.
_dragStartTimer?.cancel();
_dragStartTimer = Timer(const Duration(milliseconds: 120), () {
// 3. If the timer fires, it's a drag. Start the continuous movement timer.
_continuousMoveTimer?.cancel();
_continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) {
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
}
});
});
},
onPanUpdate: (details) {
_updateOffset(details.localPosition);
},
onPanEnd: (details) {
setState(() {
_offset = Offset.zero;
_isPressed = false;
});
widget.cursorModel.blockEvents = false;
// 4. Critical step: On pan end, cancel all timers.
// If it was a flick, this cancels the drag detection before it fires.
// If it was a drag, this stops the continuous movement.
_stopSendEventTimer();
},
child: CustomPaint(
size: Size(_joystickRadius * 2, _joystickRadius * 2),
painter: _JoystickPainter(
_offset, _joystickRadius, _thumbRadius, _isPressed),
),
),
);
}
void _updateOffset(Offset localPosition) {
final center = Offset(_joystickRadius, _joystickRadius);
final offset = localPosition - center;
final distance = offset.distance;
if (distance <= _joystickRadius) {
setState(() {
_offset = offset;
});
} else {
final clampedOffset = offset / distance * _joystickRadius;
setState(() {
_offset = clampedOffset;
});
}
}
}
class _JoystickPainter extends CustomPainter {
final Offset _offset;
final double _joystickRadius;
final double _thumbRadius;
final bool _isPressed;
_JoystickPainter(
this._offset, this._joystickRadius, this._thumbRadius, this._isPressed);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final joystickColor = _kDefaultColor;
final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor;
final thumbColor = _kWidgetHighlightColor;
final joystickPaint = Paint()
..color = joystickColor
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final thumbPaint = Paint()
..color = thumbColor
..style = PaintingStyle.fill;
// Draw joystick base and border
canvas.drawCircle(center, _joystickRadius, joystickPaint);
canvas.drawCircle(center, _joystickRadius, borderPaint);
// Draw thumb
final thumbCenter = center + _offset;
canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint);
}
@override
bool shouldRepaint(covariant _JoystickPainter oldDelegate) {
return oldDelegate._offset != _offset ||
oldDelegate._isPressed != _isPressed;
}
}
@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons {
static const String _family = 'gestureicons';
GestureIcons._();
static const IconData iconMouse = IconData(0xe65c, fontFamily: _family);
static const IconData iconTabletTouch = IconData(0xe9ce, fontFamily: _family);
static const IconData iconGestureFDrag =
IconData(0xe686, fontFamily: _family);
static const IconData iconMobileTouch = IconData(0xe9cd, fontFamily: _family);
static const IconData iconGesturePress =
IconData(0xe66c, fontFamily: _family);
static const IconData iconGestureTap = IconData(0xe66f, fontFamily: _family);
static const IconData iconGesturePinch =
IconData(0xe66a, fontFamily: _family);
static const IconData iconGesturePressHold =
IconData(0xe66b, fontFamily: _family);
static const IconData iconGestureFDragUpDown_ =
IconData(0xe685, fontFamily: _family);
static const IconData iconGestureFTap_ =
IconData(0xe68e, fontFamily: _family);
static const IconData iconGestureFSwipeRight =
IconData(0xe68f, fontFamily: _family);
static const IconData iconGestureFdoubleTap =
IconData(0xe691, fontFamily: _family);
static const IconData iconGestureFThreeFingers =
IconData(0xe687, fontFamily: _family);
}
typedef OnTouchModeChange = void Function(bool);
class GestureHelp extends StatefulWidget {
GestureHelp(
{Key? key,
required this.touchMode,
required this.onTouchModeChange,
required this.virtualMouseMode,
this.inputModel})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode;
final InputModel? inputModel;
@override
State<StatefulWidget> createState() =>
_GestureHelpState(touchMode, virtualMouseMode);
}
class _GestureHelpState extends State<GestureHelp> {
late int _selectedIndex;
late bool _touchMode;
final VirtualMouseMode _virtualMouseMode;
_GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode)
: _virtualMouseMode = virtualMouseMode {
_touchMode = touchMode;
_selectedIndex = _touchMode ? 1 : 0;
}
/// Helper to exit relative mouse mode when certain conditions are met.
/// This reduces code duplication across multiple UI callbacks.
void _exitRelativeMouseModeIf(bool condition) {
if (condition) {
widget.inputModel?.setRelativeMouseMode(false);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final space = 12.0;
var width = size.width - 2 * space;
final minWidth = 90;
if (size.width > minWidth + 2 * space) {
final n = (size.width / (minWidth + 2 * space)).floor();
width = size.width / n - 2 * space;
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ToggleSwitch(
initialLabelIndex: _selectedIndex,
activeFgColor: Colors.white,
inactiveFgColor: Colors.white60,
activeBgColor: [MyTheme.accent],
inactiveBgColor: Theme.of(context).hintColor,
totalSwitches: 2,
minWidth: 150,
fontSize: 15,
iconSize: 18,
labels: [
translate("Mouse mode"),
translate("Touch mode")
],
icons: [Icons.mouse, Icons.touch_app],
onToggle: (index) {
setState(() {
if (_selectedIndex != index) {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
// Exit relative mouse mode when switching to touch mode
_exitRelativeMouseModeIf(_touchMode);
}
});
},
),
Transform.translate(
offset: const Offset(-10.0, 0.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _virtualMouseMode.showVirtualMouse,
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
child: Text(translate('Show virtual mouse')),
),
],
),
),
if (_touchMode && _virtualMouseMode.showVirtualMouse)
Padding(
// Indent "Virtual mouse size"
padding: const EdgeInsets.only(left: 24.0),
child: SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: 0.0, bottom: 0),
child: Text(translate('Virtual mouse size')),
),
Transform.translate(
offset: Offset(-0.0, -6.0),
child: Row(
children: [
Padding(
padding:
const EdgeInsets.only(left: 0.0),
child: Text(translate('Small')),
),
Expanded(
child: Slider(
value: _virtualMouseMode
.virtualMouseScale,
min: 0.8,
max: 1.8,
divisions: 10,
onChanged: (value) {
_virtualMouseMode
.setVirtualMouseScale(value);
setState(() {});
},
),
),
Padding(
padding:
const EdgeInsets.only(right: 16.0),
child: Text(translate('Large')),
),
],
),
),
],
),
),
),
if (!_touchMode && _virtualMouseMode.showVirtualMouse)
Transform.translate(
offset: const Offset(-10.0, -12.0),
child: Padding(
// Indent "Show virtual joystick"
padding: const EdgeInsets.only(left: 24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value:
_virtualMouseMode.showVirtualJoystick,
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
child: Text(
translate("Show virtual joystick")),
),
],
)),
),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
],
),
),
Container(
child: Wrap(
spacing: space,
runSpacing: 2 * space,
children: _touchMode
? [
GestureInfo(
width,
GestureIcons.iconMobileTouch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.iconGesturePressHold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.iconGestureFSwipeRight,
translate("One-Finger Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.iconGestureFThreeFingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.iconGestureFDrag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.iconGesturePinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
]
: [
GestureInfo(
width,
GestureIcons.iconMobileTouch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.iconGesturePressHold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.iconGestureFSwipeRight,
translate("Double Tap & Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.iconGestureFThreeFingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.iconGestureFDrag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.iconGesturePinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
],
)),
],
)));
}
}
class GestureInfo extends StatelessWidget {
const GestureInfo(this.width, this.icon, this.fromText, this.toText,
{Key? key})
: super(key: key);
final String fromText;
final String toText;
final IconData icon;
final double width;
final iconSize = 35.0;
final iconColor = MyTheme.accent;
@override
Widget build(BuildContext context) {
return Container(
width: width,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: iconColor,
),
SizedBox(height: 6),
Text(fromText,
textAlign: TextAlign.center,
style:
TextStyle(fontSize: 9, color: Theme.of(context).hintColor)),
SizedBox(height: 3),
Text(toText,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodySmall?.color))
],
));
}
}
File diff suppressed because it is too large Load Diff
+562
View File
@@ -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
}
+328
View File
@@ -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
+378
View File
@@ -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
+290
View File
@@ -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) {}
}
+287
View File
@@ -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 [];
}
}
+270
View File
@@ -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();
}
}
}
+16
View File
@@ -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);
}
+48
View File
@@ -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
+955
View File
@@ -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,
);
});
}
+147
View File
@@ -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;
+497
View File
@@ -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();
}
}
+246
View File
@@ -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 [];
}
}
}
+195
View File
@@ -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);
};
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'dart:io';
final isAndroid_ = Platform.isAndroid;
final isIOS_ = Platform.isIOS;
final isWindows_ = Platform.isWindows;
final isMacOS_ = Platform.isMacOS;
final isLinux_ = Platform.isLinux;
final isWeb_ = false;
final isWebDesktop_ = false;
final isDesktop_ = Platform.isWindows || Platform.isMacOS || Platform.isLinux;
String get screenInfo_ => '';
final isWebOnWindows_ = false;
final isWebOnLinux_ = false;
final isWebOnMacOS_ = false;
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter_custom_cursor/cursor_manager.dart'
as custom_cursor_manager;
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hbb/models/model.dart';
deleteCustomCursor(String key) =>
custom_cursor_manager.CursorManager.instance.deleteCursor(key);
resetSystemCursor() {}
MouseCursor buildCursorOfCache(
CursorModel cursor, double scale, CursorData? cache) {
if (cache == null) {
return MouseCursor.defer;
} else {
final key = cache.updateGetKey(scale);
if (!cursor.cachedKeys.contains(key)) {
// data should be checked here, because it may be changed after `updateGetKey()`
final data = cache.data;
if (data == null) {
return MouseCursor.defer;
}
debugPrint(
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
// [Safety]
// It's ok to call async registerCursor in current synchronous context,
// because activating the cursor is also an async call and will always
// be executed after this.
custom_cursor_manager.CursorManager.instance
.registerCursor(custom_cursor_manager.CursorData()
..name = key
..buffer = data
..width = (cache.width * cache.scale).toInt()
..height = (cache.height * cache.scale).toInt()
..hotX = cache.hotx
..hotY = cache.hoty);
cursor.addKey(key);
}
return FlutterCustomMemoryImageCursor(key: key);
}
}
+41
View File
@@ -0,0 +1,41 @@
import 'dart:ffi' hide Size;
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart' as win32;
/// Get windows target build number.
///
/// [Note]
/// Please use this function wrapped with `Platform.isWindows`.
int getWindowsTargetBuildNumber_() {
final rtlGetVersion = DynamicLibrary.open('ntdll.dll').lookupFunction<
Void Function(Pointer<win32.OSVERSIONINFOEX>),
void Function(Pointer<win32.OSVERSIONINFOEX>)>('RtlGetVersion');
final osVersionInfo = _getOSVERSIONINFOEXPointer();
rtlGetVersion(osVersionInfo);
int buildNumber = osVersionInfo.ref.dwBuildNumber;
calloc.free(osVersionInfo);
return buildNumber;
}
/// Get Windows OS version pointer
///
/// [Note]
/// Please use this function wrapped with `Platform.isWindows`.
Pointer<win32.OSVERSIONINFOEX> _getOSVERSIONINFOEXPointer() {
final pointer = calloc<win32.OSVERSIONINFOEX>();
pointer.ref
..dwOSVersionInfoSize = sizeOf<win32.OSVERSIONINFOEX>()
..dwBuildNumber = 0
..dwMajorVersion = 0
..dwMinorVersion = 0
..dwPlatformId = 0
..szCSDVersion = ''
..wServicePackMajor = 0
..wServicePackMinor = 0
..wSuiteMask = 0
..wProductType = 0
..wReserved = 0;
return pointer;
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:convert';
typedef PluginId = String;
// ui location
const String kLocationHostMainPlugin = 'host|main|settings|plugin';
const String kLocationClientRemoteToolbarDisplay =
'client|remote|toolbar|display';
class MsgFromUi {
String id;
String name;
String location;
String key;
String value;
String action;
MsgFromUi({
required this.id,
required this.name,
required this.location,
required this.key,
required this.value,
required this.action,
});
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'name': name,
'location': location,
'key': key,
'value': value,
'action': action,
};
}
@override
String toString() {
return jsonEncode(toJson());
}
}
+18
View File
@@ -0,0 +1,18 @@
import 'dart:convert';
import 'package:flutter/material.dart';
void handlePluginEvent(
Map<String, dynamic> evt,
Function(Map<String, dynamic> e) handleMsgBox,
) {
Map<String, dynamic>? content;
try {
content = json.decode(evt['content']);
} catch (e) {
debugPrint(
'Json decode plugin event content failed: $e, ${evt['content']}');
}
if (content?['t'] == 'MsgBox') {
handleMsgBox(content?['c']);
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/plugin/ui_manager.dart';
import 'package:flutter_hbb/plugin/utils/dialogs.dart';
abstract class NativeHandler {
bool onEvent(Map<String, dynamic> evt);
}
typedef OnSelectPeersCallback = Bool Function(Int returnCode,
Pointer<Void> data, Uint64 dataLength, Pointer<Void> userData);
typedef OnSelectPeersCallbackDart = bool Function(
int returnCode, Pointer<Void> data, int dataLength, Pointer<Void> userData);
class NativeUiHandler extends NativeHandler {
NativeUiHandler._();
static NativeUiHandler instance = NativeUiHandler._();
@override
bool onEvent(Map<String, dynamic> evt) {
final name = evt['name'];
final action = evt['action'];
if (name != "native_ui") {
return false;
}
switch (action) {
case "select_peers":
int cb = evt['cb'];
int userData = evt['user_data'] ?? 0;
final cbFuncNative = Pointer.fromAddress(cb)
.cast<NativeFunction<OnSelectPeersCallback>>();
final cbFuncDart = cbFuncNative.asFunction<OnSelectPeersCallbackDart>();
onSelectPeers(cbFuncDart, userData);
break;
case "register_ui_entry":
int cb = evt['on_tap_cb'];
int userData = evt['user_data'] ?? 0;
String title = evt['title'] ?? "";
final cbFuncNative = Pointer.fromAddress(cb)
.cast<NativeFunction<OnSelectPeersCallback>>();
final cbFuncDart = cbFuncNative.asFunction<OnSelectPeersCallbackDart>();
onRegisterUiEntry(title, cbFuncDart, userData);
break;
default:
return false;
}
return true;
}
void onSelectPeers(OnSelectPeersCallbackDart cb, int userData) async {
showPeerSelectionDialog(onPeersCallback: (peers) {
String json = jsonEncode(<String, dynamic> {
"peers": peers
});
final native = json.toNativeUtf8();
cb(0, native.cast(), native.length, Pointer.fromAddress(userData));
malloc.free(native);
});
}
void onRegisterUiEntry(String title, OnSelectPeersCallbackDart cbFuncDart, int userData) {
Widget widget = InkWell(
child: Container(
height: 25.0,
child: Row(
children: [
Expanded(child: Text(title)),
Icon(Icons.chevron_right_rounded, size: 12.0,)
],
),
),
);
PluginUiManager.instance.registerEntry(title, widget);
}
}

Some files were not shown because too many files have changed in this diff Show More