mirror of
https://github.com/rustdesk/rustdesk.git
synced 2024-12-16 12:39:06 +08:00
b6035fbbdf
Signed-off-by: 21pages <sunboeasy@gmail.com>
3573 lines
109 KiB
Dart
3573 lines
109 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:back_button_interceptor/back_button_interceptor.dart';
|
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hbb/common/formatter/id_formatter.dart';
|
|
import 'package:flutter_hbb/desktop/widgets/refresh_wrapper.dart';
|
|
import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart';
|
|
import 'package:flutter_hbb/main.dart';
|
|
import 'package:flutter_hbb/models/peer_model.dart';
|
|
import 'package:flutter_hbb/models/state_model.dart';
|
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
|
import 'package:flutter_hbb/utils/platform_channel.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:uni_links/uni_links.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
import 'package:window_manager/window_manager.dart';
|
|
import 'package:window_size/window_size.dart' as window_size;
|
|
|
|
import '../consts.dart';
|
|
import 'common/widgets/overlay.dart';
|
|
import 'mobile/pages/file_manager_page.dart';
|
|
import 'mobile/pages/remote_page.dart';
|
|
import 'desktop/pages/remote_page.dart' as desktop_remote;
|
|
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
|
import 'models/model.dart';
|
|
import 'models/platform_model.dart';
|
|
|
|
import 'package:flutter_hbb/native/win32.dart'
|
|
if (dart.library.html) 'package:flutter_hbb/web/win32.dart';
|
|
import 'package:flutter_hbb/native/common.dart'
|
|
if (dart.library.html) 'package:flutter_hbb/web/common.dart';
|
|
|
|
final globalKey = GlobalKey<NavigatorState>();
|
|
final navigationBarKey = GlobalKey();
|
|
|
|
final isAndroid = isAndroid_;
|
|
final isIOS = isIOS_;
|
|
final isWindows = isWindows_;
|
|
final isMacOS = isMacOS_;
|
|
final isLinux = isLinux_;
|
|
final isDesktop = isDesktop_;
|
|
final isWeb = isWeb_;
|
|
final isWebDesktop = isWebDesktop_;
|
|
var isMobile = isAndroid || isIOS;
|
|
var version = '';
|
|
int androidVersion = 0;
|
|
|
|
// Only used on Linux.
|
|
// `windowManager.setResizable(false)` will reset the window size to the default size on Linux.
|
|
// https://stackoverflow.com/questions/8193613/gtk-window-resize-disable-without-going-back-to-default
|
|
// So we need to use this flag to enable/disable resizable.
|
|
bool _linuxWindowResizable = true;
|
|
|
|
/// only available for Windows target
|
|
int windowsBuildNumber = 0;
|
|
DesktopType? desktopType;
|
|
|
|
bool get isMainDesktopWindow =>
|
|
desktopType == DesktopType.main || desktopType == DesktopType.cm;
|
|
|
|
String get screenInfo => screenInfo_;
|
|
|
|
/// Check if the app is running with single view mode.
|
|
bool isSingleViewApp() {
|
|
return desktopType == DesktopType.cm;
|
|
}
|
|
|
|
/// * debug or test only, DO NOT enable in release build
|
|
bool isTest = false;
|
|
|
|
typedef F = String Function(String);
|
|
typedef FMethod = String Function(String, dynamic);
|
|
|
|
typedef StreamEventHandler = Future<void> Function(Map<String, dynamic>);
|
|
typedef SessionID = UuidValue;
|
|
final iconHardDrive = MemoryImage(Uint8List.fromList(base64Decode(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAMAAACahl6sAAAAmVBMVEUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjHWqVAAAAMnRSTlMAv0BmzLJNXlhiUu2fxXDgu7WuSUUe29LJvpqUjX53VTstD7ilNujCqTEk5IYH+vEoFjKvAagAAAPpSURBVHja7d0JbhpBEIXhB3jYzb5vBgzYgO04df/DJXGUKMwU9ECmZ6pQfSfw028LCXW3YYwxxhhjjDHGGGOM0eZ9VV1MckdKWLM1bRQ/35GW/WxHHu1me6ShuyHvNl34VhlTKsYVeDWj1EzgUZ1S1DrAk/UDparZgxd9Sl0BHnxSBhpI3jfKQG2FpLUpE69I2ILikv1nsvygjBwPSNKYMlNHggqUoSKS80AZCnwHqQ1zCRvW+CRegwRFeFAMKKrtM8gTPJlzSfwFgT9dJom3IDN4VGaSeAryAK8m0SSeghTg1ZYiql6CjBDhO8mzlyAVhKhIwgXxrh5NojGIhyRckEdwpCdhgpSQgiWTRGMQNonGIGySp0SDvMDBX5KWxiB8Eo1BgE00SYJBykhNnkmSWJAcLpGaJNMgfJKyxiDAK4WNEwryhMtkJsk8CJtEYxA+icYgQIfCcgkEqcJNXhIRQdgkGoPwSTQG+e8khdu/7JOVREwQIKCwF41B2CQljUH4JLcH6SI+OUlEBQHa0SQag/BJNAbhkjxqDMIn0RgEeI4muSlID9eSkERgEKAVTaIxCJ9EYxA2ydVB8hCASVLRGAQYR5NoDMIn0RgEyFHYSGMQPonGII4kziCNvBgNJonEk4u3GAk8Sprk6eYaqbMDY0oKvUm5jfC/viGiSypV7+M3i2iDsAGpNEDYjlTa3W8RdR/r544g50ilnA0RxoZIE2NIXqQbhkAkGyKNDZHGhkhjQ6SxIdLYEGlsiDQ2JGTVeD0264U9zipPh7XOooffpA6pfNCXjxl4/c3pUzlChwzor53zwYYVfpI5pOV6LWFF/2jiJ5FDSs5jdY/0rwUAkUMeXWdBqnSqD0DikBqdqCHsjTvELm9In0IOri/0pwAEDtlSyNaRjAIAAoesKWTtuusxByBwCJp0oomwBXcYUuCQgE50ENajE4OvZAKHLB1/68Br5NqiyCGYOY8YRd77kTkEb64n7lZN+mOIX4QOwb5FX0ZVx3uOxwW+SB0CbBubemWP8/rlaaeRX+M3uUOuZENsiA25zIbYkPsZElBIHwL13U/PTjJ/cyOOEoVM3I+hziDQlELm7pPxw3eI8/7gPh1fpLA6xGnEeDDgO0UcIAzzM35HxLPIq5SXe9BLzOsj9eUaQqyXzxS1QFSfWM2cCANiHcAISJ0AnCKpUwTuIkkA3EeSInAXSQKcs1V18e24wlllUmQp9v9zXKeHi+akRAMOPVKhAqdPBZeUmnnEsO6QcJ0+4qmOSbBxFfGVRiTUqITrdKcCbyYO3/K4wX4+aQ+FfNjXhu3JfAVjjDHGGGOMMcYYY4xIPwCgfqT6TbhCLAAAAABJRU5ErkJggg==')));
|
|
|
|
enum DesktopType {
|
|
main,
|
|
remote,
|
|
fileTransfer,
|
|
cm,
|
|
portForward,
|
|
}
|
|
|
|
class IconFont {
|
|
static const _family1 = 'Tabbar';
|
|
static const _family2 = 'PeerSearchbar';
|
|
IconFont._();
|
|
|
|
static const IconData max = IconData(0xe606, fontFamily: _family1);
|
|
static const IconData restore = IconData(0xe607, fontFamily: _family1);
|
|
static const IconData close = IconData(0xe668, fontFamily: _family1);
|
|
static const IconData min = IconData(0xe609, fontFamily: _family1);
|
|
static const IconData add = IconData(0xe664, fontFamily: _family1);
|
|
static const IconData menu = IconData(0xe628, fontFamily: _family1);
|
|
static const IconData search = IconData(0xe6a4, fontFamily: _family2);
|
|
static const IconData roundClose = IconData(0xe6ed, fontFamily: _family2);
|
|
static const IconData addressBook =
|
|
IconData(0xe602, fontFamily: "AddressBook");
|
|
}
|
|
|
|
class ColorThemeExtension extends ThemeExtension<ColorThemeExtension> {
|
|
const ColorThemeExtension({
|
|
required this.border,
|
|
required this.border2,
|
|
required this.border3,
|
|
required this.highlight,
|
|
required this.drag_indicator,
|
|
required this.shadow,
|
|
required this.errorBannerBg,
|
|
required this.me,
|
|
required this.toastBg,
|
|
required this.toastText,
|
|
required this.divider,
|
|
});
|
|
|
|
final Color? border;
|
|
final Color? border2;
|
|
final Color? border3;
|
|
final Color? highlight;
|
|
final Color? drag_indicator;
|
|
final Color? shadow;
|
|
final Color? errorBannerBg;
|
|
final Color? me;
|
|
final Color? toastBg;
|
|
final Color? toastText;
|
|
final Color? divider;
|
|
|
|
static final light = ColorThemeExtension(
|
|
border: Color(0xFFCCCCCC),
|
|
border2: Color(0xFFBBBBBB),
|
|
border3: Colors.black26,
|
|
highlight: Color(0xFFE5E5E5),
|
|
drag_indicator: Colors.grey[800],
|
|
shadow: Colors.black,
|
|
errorBannerBg: Color(0xFFFDEEEB),
|
|
me: Colors.green,
|
|
toastBg: Colors.black.withOpacity(0.6),
|
|
toastText: Colors.white,
|
|
divider: Colors.black38,
|
|
);
|
|
|
|
static final dark = ColorThemeExtension(
|
|
border: Color(0xFF555555),
|
|
border2: Color(0xFFE5E5E5),
|
|
border3: Colors.white24,
|
|
highlight: Color(0xFF3F3F3F),
|
|
drag_indicator: Colors.grey,
|
|
shadow: Colors.grey,
|
|
errorBannerBg: Color(0xFF470F2D),
|
|
me: Colors.greenAccent,
|
|
toastBg: Colors.white.withOpacity(0.6),
|
|
toastText: Colors.black,
|
|
divider: Colors.white38,
|
|
);
|
|
|
|
@override
|
|
ThemeExtension<ColorThemeExtension> copyWith({
|
|
Color? border,
|
|
Color? border2,
|
|
Color? border3,
|
|
Color? highlight,
|
|
Color? drag_indicator,
|
|
Color? shadow,
|
|
Color? errorBannerBg,
|
|
Color? me,
|
|
Color? toastBg,
|
|
Color? toastText,
|
|
Color? divider,
|
|
}) {
|
|
return ColorThemeExtension(
|
|
border: border ?? this.border,
|
|
border2: border2 ?? this.border2,
|
|
border3: border3 ?? this.border3,
|
|
highlight: highlight ?? this.highlight,
|
|
drag_indicator: drag_indicator ?? this.drag_indicator,
|
|
shadow: shadow ?? this.shadow,
|
|
errorBannerBg: errorBannerBg ?? this.errorBannerBg,
|
|
me: me ?? this.me,
|
|
toastBg: toastBg ?? this.toastBg,
|
|
toastText: toastText ?? this.toastText,
|
|
divider: divider ?? this.divider,
|
|
);
|
|
}
|
|
|
|
@override
|
|
ThemeExtension<ColorThemeExtension> lerp(
|
|
ThemeExtension<ColorThemeExtension>? other, double t) {
|
|
if (other is! ColorThemeExtension) {
|
|
return this;
|
|
}
|
|
return ColorThemeExtension(
|
|
border: Color.lerp(border, other.border, t),
|
|
border2: Color.lerp(border2, other.border2, t),
|
|
border3: Color.lerp(border3, other.border3, t),
|
|
highlight: Color.lerp(highlight, other.highlight, t),
|
|
drag_indicator: Color.lerp(drag_indicator, other.drag_indicator, t),
|
|
shadow: Color.lerp(shadow, other.shadow, t),
|
|
errorBannerBg: Color.lerp(shadow, other.errorBannerBg, t),
|
|
me: Color.lerp(shadow, other.me, t),
|
|
toastBg: Color.lerp(shadow, other.toastBg, t),
|
|
toastText: Color.lerp(shadow, other.toastText, t),
|
|
divider: Color.lerp(shadow, other.divider, t),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MyTheme {
|
|
MyTheme._();
|
|
|
|
static const Color grayBg = Color(0xFFEFEFF2);
|
|
static const Color accent = Color(0xFF0071FF);
|
|
static const Color accent50 = Color(0x770071FF);
|
|
static const Color accent80 = Color(0xAA0071FF);
|
|
static const Color canvasColor = Color(0xFF212121);
|
|
static const Color border = Color(0xFFCCCCCC);
|
|
static const Color idColor = Color(0xFF00B6F0);
|
|
static const Color darkGray = Color.fromARGB(255, 148, 148, 148);
|
|
static const Color cmIdColor = Color(0xFF21790B);
|
|
static const Color dark = Colors.black87;
|
|
static const Color button = Color(0xFF2C8CFF);
|
|
static const Color hoverBorder = Color(0xFF999999);
|
|
|
|
// ListTile
|
|
static const ListTileThemeData listTileTheme = ListTileThemeData(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(
|
|
Radius.circular(5),
|
|
),
|
|
),
|
|
);
|
|
|
|
static SwitchThemeData switchTheme() {
|
|
return SwitchThemeData(
|
|
splashRadius: (isDesktop || isWebDesktop) ? 0 : kRadialReactionRadius);
|
|
}
|
|
|
|
static RadioThemeData radioTheme() {
|
|
return RadioThemeData(
|
|
splashRadius: (isDesktop || isWebDesktop) ? 0 : kRadialReactionRadius);
|
|
}
|
|
|
|
// Checkbox
|
|
static const CheckboxThemeData checkboxTheme = CheckboxThemeData(
|
|
splashRadius: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(
|
|
Radius.circular(5),
|
|
),
|
|
),
|
|
);
|
|
|
|
// TextButton
|
|
// Value is used to calculate "dialog.actionsPadding"
|
|
static const double mobileTextButtonPaddingLR = 20;
|
|
|
|
// TextButton on mobile needs a fixed padding, otherwise small buttons
|
|
// like "OK" has a larger left/right padding.
|
|
static TextButtonThemeData mobileTextButtonTheme = TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
padding: EdgeInsets.symmetric(horizontal: mobileTextButtonPaddingLR),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
),
|
|
);
|
|
|
|
//tooltip
|
|
static TooltipThemeData tooltipTheme() {
|
|
return TooltipThemeData(
|
|
waitDuration: Duration(seconds: 1, milliseconds: 500),
|
|
);
|
|
}
|
|
|
|
// Dialogs
|
|
static const double dialogPadding = 24;
|
|
|
|
// padding bottom depends on content (some dialogs has no content)
|
|
static EdgeInsets dialogTitlePadding({bool content = true}) {
|
|
final double p = dialogPadding;
|
|
|
|
return EdgeInsets.fromLTRB(p, p, p, content ? 0 : p);
|
|
}
|
|
|
|
// padding bottom depends on actions (mobile has dialogs without actions)
|
|
static EdgeInsets dialogContentPadding({bool actions = true}) {
|
|
final double p = dialogPadding;
|
|
|
|
return (isDesktop || isWebDesktop)
|
|
? EdgeInsets.fromLTRB(p, p, p, actions ? (p - 4) : p)
|
|
: EdgeInsets.fromLTRB(p, p, p, actions ? (p / 2) : p);
|
|
}
|
|
|
|
static EdgeInsets dialogActionsPadding() {
|
|
final double p = dialogPadding;
|
|
|
|
return (isDesktop || isWebDesktop)
|
|
? EdgeInsets.fromLTRB(p, 0, p, (p - 4))
|
|
: EdgeInsets.fromLTRB(p, 0, (p - mobileTextButtonPaddingLR), (p / 2));
|
|
}
|
|
|
|
static EdgeInsets dialogButtonPadding = (isDesktop || isWebDesktop)
|
|
? EdgeInsets.only(left: dialogPadding)
|
|
: EdgeInsets.only(left: dialogPadding / 3);
|
|
|
|
static ScrollbarThemeData scrollbarTheme = ScrollbarThemeData(
|
|
thickness: MaterialStateProperty.all(6),
|
|
thumbColor: MaterialStateProperty.resolveWith<Color?>((states) {
|
|
if (states.contains(MaterialState.dragged)) {
|
|
return Colors.grey[900];
|
|
} else if (states.contains(MaterialState.hovered)) {
|
|
return Colors.grey[700];
|
|
} else {
|
|
return Colors.grey[500];
|
|
}
|
|
}),
|
|
crossAxisMargin: 4,
|
|
);
|
|
|
|
static ScrollbarThemeData scrollbarThemeDark = scrollbarTheme.copyWith(
|
|
thumbColor: MaterialStateProperty.resolveWith<Color?>((states) {
|
|
if (states.contains(MaterialState.dragged)) {
|
|
return Colors.grey[100];
|
|
} else if (states.contains(MaterialState.hovered)) {
|
|
return Colors.grey[300];
|
|
} else {
|
|
return Colors.grey[500];
|
|
}
|
|
}),
|
|
);
|
|
|
|
static ThemeData lightTheme = ThemeData(
|
|
// https://stackoverflow.com/questions/77537315/after-upgrading-to-flutter-3-16-the-app-bar-background-color-button-size-and
|
|
useMaterial3: false,
|
|
brightness: Brightness.light,
|
|
hoverColor: Color.fromARGB(255, 224, 224, 224),
|
|
scaffoldBackgroundColor: Colors.white,
|
|
dialogBackgroundColor: Colors.white,
|
|
dialogTheme: DialogTheme(
|
|
elevation: 15,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18.0),
|
|
side: BorderSide(
|
|
width: 1,
|
|
color: grayBg,
|
|
),
|
|
),
|
|
),
|
|
scrollbarTheme: scrollbarTheme,
|
|
inputDecorationTheme: isDesktop
|
|
? InputDecorationTheme(
|
|
fillColor: grayBg,
|
|
filled: true,
|
|
isDense: true,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
)
|
|
: null,
|
|
textTheme: const TextTheme(
|
|
titleLarge: TextStyle(fontSize: 19, color: Colors.black87),
|
|
titleSmall: TextStyle(fontSize: 14, color: Colors.black87),
|
|
bodySmall: TextStyle(fontSize: 12, color: Colors.black87, height: 1.25),
|
|
bodyMedium:
|
|
TextStyle(fontSize: 14, color: Colors.black87, height: 1.25),
|
|
labelLarge: TextStyle(fontSize: 16.0, color: MyTheme.accent80)),
|
|
cardColor: grayBg,
|
|
hintColor: Color(0xFFAAAAAA),
|
|
visualDensity: VisualDensity.adaptivePlatformDensity,
|
|
tabBarTheme: const TabBarTheme(
|
|
labelColor: Colors.black87,
|
|
),
|
|
tooltipTheme: tooltipTheme(),
|
|
splashColor: (isDesktop || isWebDesktop) ? Colors.transparent : null,
|
|
highlightColor: (isDesktop || isWebDesktop) ? Colors.transparent : null,
|
|
splashFactory: (isDesktop || isWebDesktop) ? NoSplash.splashFactory : null,
|
|
textButtonTheme: (isDesktop || isWebDesktop)
|
|
? TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
splashFactory: NoSplash.splashFactory,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18.0),
|
|
),
|
|
),
|
|
)
|
|
: mobileTextButtonTheme,
|
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: MyTheme.accent,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
),
|
|
),
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
style: OutlinedButton.styleFrom(
|
|
backgroundColor: grayBg,
|
|
foregroundColor: Colors.black87,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
),
|
|
),
|
|
switchTheme: switchTheme(),
|
|
radioTheme: radioTheme(),
|
|
checkboxTheme: checkboxTheme,
|
|
listTileTheme: listTileTheme,
|
|
menuBarTheme: MenuBarThemeData(
|
|
style:
|
|
MenuStyle(backgroundColor: MaterialStatePropertyAll(Colors.white))),
|
|
colorScheme: ColorScheme.light(
|
|
primary: Colors.blue, secondary: accent, background: grayBg),
|
|
popupMenuTheme: PopupMenuThemeData(
|
|
color: Colors.white,
|
|
shape: RoundedRectangleBorder(
|
|
side: BorderSide(
|
|
color: (isDesktop || isWebDesktop)
|
|
? Color(0xFFECECEC)
|
|
: Colors.transparent),
|
|
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
|
)),
|
|
).copyWith(
|
|
extensions: <ThemeExtension<dynamic>>[
|
|
ColorThemeExtension.light,
|
|
TabbarTheme.light,
|
|
],
|
|
);
|
|
static ThemeData darkTheme = ThemeData(
|
|
useMaterial3: false,
|
|
brightness: Brightness.dark,
|
|
hoverColor: Color.fromARGB(255, 45, 46, 53),
|
|
scaffoldBackgroundColor: Color(0xFF18191E),
|
|
dialogBackgroundColor: Color(0xFF18191E),
|
|
dialogTheme: DialogTheme(
|
|
elevation: 15,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18.0),
|
|
side: BorderSide(
|
|
width: 1,
|
|
color: Color(0xFF24252B),
|
|
),
|
|
),
|
|
),
|
|
scrollbarTheme: scrollbarThemeDark,
|
|
inputDecorationTheme: (isDesktop || isWebDesktop)
|
|
? InputDecorationTheme(
|
|
fillColor: Color(0xFF24252B),
|
|
filled: true,
|
|
isDense: true,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
)
|
|
: null,
|
|
textTheme: const TextTheme(
|
|
titleLarge: TextStyle(fontSize: 19),
|
|
titleSmall: TextStyle(fontSize: 14),
|
|
bodySmall: TextStyle(fontSize: 12, height: 1.25),
|
|
bodyMedium: TextStyle(fontSize: 14, height: 1.25),
|
|
labelLarge: TextStyle(
|
|
fontSize: 16.0,
|
|
fontWeight: FontWeight.bold,
|
|
color: accent80,
|
|
),
|
|
),
|
|
cardColor: Color(0xFF24252B),
|
|
visualDensity: VisualDensity.adaptivePlatformDensity,
|
|
tabBarTheme: const TabBarTheme(
|
|
labelColor: Colors.white70,
|
|
),
|
|
tooltipTheme: tooltipTheme(),
|
|
splashColor: (isDesktop || isWebDesktop) ? Colors.transparent : null,
|
|
highlightColor: (isDesktop || isWebDesktop) ? Colors.transparent : null,
|
|
splashFactory: (isDesktop || isWebDesktop) ? NoSplash.splashFactory : null,
|
|
textButtonTheme: (isDesktop || isWebDesktop)
|
|
? TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
splashFactory: NoSplash.splashFactory,
|
|
disabledForegroundColor: Colors.white70,
|
|
foregroundColor: Colors.white70,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18.0),
|
|
),
|
|
),
|
|
)
|
|
: mobileTextButtonTheme,
|
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: MyTheme.accent,
|
|
foregroundColor: Colors.white,
|
|
disabledForegroundColor: Colors.white70,
|
|
disabledBackgroundColor: Colors.white10,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
),
|
|
),
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
style: OutlinedButton.styleFrom(
|
|
backgroundColor: Color(0xFF24252B),
|
|
side: BorderSide(color: Colors.white12, width: 0.5),
|
|
disabledForegroundColor: Colors.white70,
|
|
foregroundColor: Colors.white70,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
),
|
|
),
|
|
switchTheme: switchTheme(),
|
|
radioTheme: radioTheme(),
|
|
checkboxTheme: checkboxTheme,
|
|
listTileTheme: listTileTheme,
|
|
menuBarTheme: MenuBarThemeData(
|
|
style: MenuStyle(
|
|
backgroundColor: MaterialStatePropertyAll(Color(0xFF121212)))),
|
|
colorScheme: ColorScheme.dark(
|
|
primary: Colors.blue,
|
|
secondary: accent,
|
|
background: Color(0xFF24252B),
|
|
),
|
|
popupMenuTheme: PopupMenuThemeData(
|
|
shape: RoundedRectangleBorder(
|
|
side: BorderSide(color: Colors.white24),
|
|
borderRadius: BorderRadius.all(Radius.circular(8.0)),
|
|
)),
|
|
).copyWith(
|
|
extensions: <ThemeExtension<dynamic>>[
|
|
ColorThemeExtension.dark,
|
|
TabbarTheme.dark,
|
|
],
|
|
);
|
|
|
|
static ThemeMode getThemeModePreference() {
|
|
return themeModeFromString(bind.mainGetLocalOption(key: kCommConfKeyTheme));
|
|
}
|
|
|
|
static void changeDarkMode(ThemeMode mode) async {
|
|
Get.changeThemeMode(mode);
|
|
if (desktopType == DesktopType.main || isAndroid || isIOS) {
|
|
if (mode == ThemeMode.system) {
|
|
await bind.mainSetLocalOption(
|
|
key: kCommConfKeyTheme, value: defaultOptionTheme);
|
|
} else {
|
|
await bind.mainSetLocalOption(
|
|
key: kCommConfKeyTheme, value: mode.toShortString());
|
|
}
|
|
await bind.mainChangeTheme(dark: mode.toShortString());
|
|
// Synchronize the window theme of the system.
|
|
updateSystemWindowTheme();
|
|
}
|
|
}
|
|
|
|
static ThemeMode currentThemeMode() {
|
|
final preference = getThemeModePreference();
|
|
if (preference == ThemeMode.system) {
|
|
if (WidgetsBinding.instance.platformDispatcher.platformBrightness ==
|
|
Brightness.light) {
|
|
return ThemeMode.light;
|
|
} else {
|
|
return ThemeMode.dark;
|
|
}
|
|
} else {
|
|
return preference;
|
|
}
|
|
}
|
|
|
|
static ColorThemeExtension color(BuildContext context) {
|
|
return Theme.of(context).extension<ColorThemeExtension>()!;
|
|
}
|
|
|
|
static TabbarTheme tabbar(BuildContext context) {
|
|
return Theme.of(context).extension<TabbarTheme>()!;
|
|
}
|
|
|
|
static ThemeMode themeModeFromString(String v) {
|
|
switch (v) {
|
|
case "light":
|
|
return ThemeMode.light;
|
|
case "dark":
|
|
return ThemeMode.dark;
|
|
default:
|
|
return ThemeMode.system;
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ParseToString on ThemeMode {
|
|
String toShortString() {
|
|
return toString().split('.').last;
|
|
}
|
|
}
|
|
|
|
final ButtonStyle flatButtonStyle = TextButton.styleFrom(
|
|
minimumSize: Size(0, 36),
|
|
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(2.0)),
|
|
),
|
|
);
|
|
|
|
List<Locale> supportedLocales = const [
|
|
Locale('en', 'US'),
|
|
Locale('zh', 'CN'),
|
|
Locale('zh', 'TW'),
|
|
Locale('zh', 'SG'),
|
|
Locale('fr'),
|
|
Locale('de'),
|
|
Locale('it'),
|
|
Locale('ja'),
|
|
Locale('cs'),
|
|
Locale('pl'),
|
|
Locale('ko'),
|
|
Locale('hu'),
|
|
Locale('pt'),
|
|
Locale('ru'),
|
|
Locale('sk'),
|
|
Locale('id'),
|
|
Locale('da'),
|
|
Locale('eo'),
|
|
Locale('tr'),
|
|
Locale('kz'),
|
|
Locale('es'),
|
|
Locale('nl'),
|
|
Locale('nb'),
|
|
Locale('et'),
|
|
Locale('eu'),
|
|
Locale('bg'),
|
|
Locale('be'),
|
|
Locale('vn'),
|
|
Locale('uk'),
|
|
Locale('fa'),
|
|
Locale('ca'),
|
|
Locale('el'),
|
|
Locale('sv'),
|
|
Locale('sq'),
|
|
Locale('sr'),
|
|
Locale('th'),
|
|
Locale('sl'),
|
|
Locale('ro'),
|
|
Locale('lt'),
|
|
Locale('lv'),
|
|
Locale('ar'),
|
|
Locale('he'),
|
|
Locale('hr'),
|
|
];
|
|
|
|
String formatDurationToTime(Duration duration) {
|
|
var totalTime = duration.inSeconds;
|
|
final secs = totalTime % 60;
|
|
totalTime = (totalTime - secs) ~/ 60;
|
|
final mins = totalTime % 60;
|
|
totalTime = (totalTime - mins) ~/ 60;
|
|
return "${totalTime.toString().padLeft(2, "0")}:${mins.toString().padLeft(2, "0")}:${secs.toString().padLeft(2, "0")}";
|
|
}
|
|
|
|
closeConnection({String? id}) {
|
|
if (isAndroid || isIOS) {
|
|
() async {
|
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
|
overlays: SystemUiOverlay.values);
|
|
gFFI.chatModel.hideChatOverlay();
|
|
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
|
|
}();
|
|
} else {
|
|
if (isWeb) {
|
|
Navigator.popUntil(globalKey.currentContext!, ModalRoute.withName("/"));
|
|
} else {
|
|
final controller = Get.find<DesktopTabController>();
|
|
controller.closeBy(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> windowOnTop(int? id) async {
|
|
if (!isDesktop) {
|
|
return;
|
|
}
|
|
print("Bring window '$id' on top");
|
|
if (id == null) {
|
|
// main window
|
|
if (stateGlobal.isMinimized) {
|
|
await windowManager.restore();
|
|
}
|
|
await windowManager.show();
|
|
await windowManager.focus();
|
|
await rustDeskWinManager.registerActiveWindow(kWindowMainId);
|
|
} else {
|
|
WindowController.fromWindowId(id)
|
|
..focus()
|
|
..show();
|
|
rustDeskWinManager.call(WindowType.Main, kWindowEventShow, {"id": id});
|
|
}
|
|
}
|
|
|
|
typedef DialogBuilder = CustomAlertDialog Function(
|
|
StateSetter setState, void Function([dynamic]) close, BuildContext context);
|
|
|
|
class Dialog<T> {
|
|
OverlayEntry? entry;
|
|
Completer<T?> completer = Completer<T?>();
|
|
|
|
Dialog();
|
|
|
|
void complete(T? res) {
|
|
try {
|
|
if (!completer.isCompleted) {
|
|
completer.complete(res);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Dialog complete catch error: $e");
|
|
} finally {
|
|
entry?.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
class OverlayKeyState {
|
|
final _overlayKey = GlobalKey<OverlayState>();
|
|
|
|
/// use global overlay by default
|
|
OverlayState? get state =>
|
|
_overlayKey.currentState ?? globalKey.currentState?.overlay;
|
|
|
|
GlobalKey<OverlayState>? get key => _overlayKey;
|
|
}
|
|
|
|
class OverlayDialogManager {
|
|
final Map<String, Dialog> _dialogs = {};
|
|
var _overlayKeyState = OverlayKeyState();
|
|
int _tagCount = 0;
|
|
|
|
OverlayEntry? _mobileActionsOverlayEntry;
|
|
RxBool mobileActionsOverlayVisible = true.obs;
|
|
|
|
setMobileActionsOverlayVisible(bool v, {store = true}) {
|
|
if (store) {
|
|
bind.setLocalFlutterOption(k: kOptionShowMobileAction, v: v ? 'Y' : 'N');
|
|
}
|
|
// No need to read the value from local storage after setting it.
|
|
// It better to toggle the value directly.
|
|
mobileActionsOverlayVisible.value = v;
|
|
}
|
|
|
|
loadMobileActionsOverlayVisible() {
|
|
mobileActionsOverlayVisible.value =
|
|
bind.getLocalFlutterOption(k: kOptionShowMobileAction) != 'N';
|
|
}
|
|
|
|
void setOverlayState(OverlayKeyState overlayKeyState) {
|
|
_overlayKeyState = overlayKeyState;
|
|
}
|
|
|
|
void dismissAll() {
|
|
_dialogs.forEach((key, value) {
|
|
value.complete(null);
|
|
BackButtonInterceptor.removeByName(key);
|
|
});
|
|
_dialogs.clear();
|
|
}
|
|
|
|
void dismissByTag(String tag) {
|
|
_dialogs[tag]?.complete(null);
|
|
_dialogs.remove(tag);
|
|
BackButtonInterceptor.removeByName(tag);
|
|
}
|
|
|
|
Future<T?> show<T>(DialogBuilder builder,
|
|
{bool clickMaskDismiss = false,
|
|
bool backDismiss = false,
|
|
String? tag,
|
|
bool useAnimation = true,
|
|
bool forceGlobal = false}) {
|
|
final overlayState =
|
|
forceGlobal ? globalKey.currentState?.overlay : _overlayKeyState.state;
|
|
|
|
if (overlayState == null) {
|
|
return Future.error(
|
|
"[OverlayDialogManager] Failed to show dialog, _overlayState is null, call [setOverlayState] first");
|
|
}
|
|
|
|
final String dialogTag;
|
|
if (tag != null) {
|
|
dialogTag = tag;
|
|
} else {
|
|
dialogTag = _tagCount.toString();
|
|
_tagCount++;
|
|
}
|
|
|
|
final dialog = Dialog<T>();
|
|
_dialogs[dialogTag] = dialog;
|
|
|
|
close([res]) {
|
|
_dialogs.remove(dialogTag);
|
|
dialog.complete(res);
|
|
BackButtonInterceptor.removeByName(dialogTag);
|
|
}
|
|
|
|
dialog.entry = OverlayEntry(builder: (context) {
|
|
bool innerClicked = false;
|
|
return Listener(
|
|
onPointerUp: (_) {
|
|
if (!innerClicked && clickMaskDismiss) {
|
|
close();
|
|
}
|
|
innerClicked = false;
|
|
},
|
|
child: Container(
|
|
color: Theme.of(context).brightness == Brightness.light
|
|
? Colors.black12
|
|
: Colors.black45,
|
|
child: StatefulBuilder(builder: (context, setState) {
|
|
return Listener(
|
|
onPointerUp: (_) => innerClicked = true,
|
|
child: builder(setState, close, overlayState.context),
|
|
);
|
|
})));
|
|
});
|
|
overlayState.insert(dialog.entry!);
|
|
BackButtonInterceptor.add((stopDefaultButtonEvent, routeInfo) {
|
|
if (backDismiss) {
|
|
close();
|
|
}
|
|
return true;
|
|
}, name: dialogTag);
|
|
return dialog.completer.future;
|
|
}
|
|
|
|
String showLoading(String text,
|
|
{bool clickMaskDismiss = false,
|
|
bool showCancel = true,
|
|
VoidCallback? onCancel,
|
|
String? tag}) {
|
|
if (tag == null) {
|
|
tag = _tagCount.toString();
|
|
_tagCount++;
|
|
}
|
|
show((setState, close, context) {
|
|
cancel() {
|
|
dismissAll();
|
|
if (onCancel != null) {
|
|
onCancel();
|
|
}
|
|
}
|
|
|
|
return CustomAlertDialog(
|
|
content: Container(
|
|
constraints: const BoxConstraints(maxWidth: 240),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 30),
|
|
const Center(child: CircularProgressIndicator()),
|
|
const SizedBox(height: 20),
|
|
Center(
|
|
child: Text(translate(text),
|
|
style: const TextStyle(fontSize: 15))),
|
|
const SizedBox(height: 20),
|
|
Offstage(
|
|
offstage: !showCancel,
|
|
child: Center(
|
|
child: (isDesktop || isWebDesktop)
|
|
? dialogButton('Cancel', onPressed: cancel)
|
|
: TextButton(
|
|
style: flatButtonStyle,
|
|
onPressed: cancel,
|
|
child: Text(translate('Cancel'),
|
|
style: const TextStyle(
|
|
color: MyTheme.accent)))))
|
|
])),
|
|
onCancel: showCancel ? cancel : null,
|
|
);
|
|
}, tag: tag);
|
|
return tag;
|
|
}
|
|
|
|
void resetMobileActionsOverlay({FFI? ffi}) {
|
|
if (_mobileActionsOverlayEntry == null) return;
|
|
hideMobileActionsOverlay();
|
|
showMobileActionsOverlay(ffi: ffi);
|
|
}
|
|
|
|
void showMobileActionsOverlay({FFI? ffi}) {
|
|
if (_mobileActionsOverlayEntry != null) return;
|
|
final overlayState = _overlayKeyState.state;
|
|
if (overlayState == null) return;
|
|
|
|
final overlay = makeMobileActionsOverlayEntry(
|
|
() => hideMobileActionsOverlay(),
|
|
ffi: ffi,
|
|
);
|
|
overlayState.insert(overlay);
|
|
_mobileActionsOverlayEntry = overlay;
|
|
setMobileActionsOverlayVisible(true);
|
|
}
|
|
|
|
void hideMobileActionsOverlay({store = true}) {
|
|
if (_mobileActionsOverlayEntry != null) {
|
|
_mobileActionsOverlayEntry!.remove();
|
|
_mobileActionsOverlayEntry = null;
|
|
setMobileActionsOverlayVisible(false, store: store);
|
|
return;
|
|
}
|
|
}
|
|
|
|
void toggleMobileActionsOverlay({FFI? ffi}) {
|
|
if (_mobileActionsOverlayEntry == null) {
|
|
showMobileActionsOverlay(ffi: ffi);
|
|
} else {
|
|
hideMobileActionsOverlay();
|
|
}
|
|
}
|
|
|
|
bool existing(String tag) {
|
|
return _dialogs.keys.contains(tag);
|
|
}
|
|
}
|
|
|
|
makeMobileActionsOverlayEntry(VoidCallback? onHide, {FFI? ffi}) {
|
|
makeMobileActions(BuildContext context, double s) {
|
|
final scale = s < 0.85 ? 0.85 : s;
|
|
final session = ffi ?? gFFI;
|
|
const double overlayW = 200;
|
|
const double overlayH = 45;
|
|
computeOverlayPosition() {
|
|
final screenW = MediaQuery.of(context).size.width;
|
|
final screenH = MediaQuery.of(context).size.height;
|
|
final left = (screenW - overlayW * scale) / 2;
|
|
final top = screenH - (overlayH + 80) * scale;
|
|
return Offset(left, top);
|
|
}
|
|
|
|
if (draggablePositions.mobileActions.isInvalid()) {
|
|
draggablePositions.mobileActions.update(computeOverlayPosition());
|
|
} else {
|
|
draggablePositions.mobileActions.tryAdjust(overlayW, overlayH, scale);
|
|
}
|
|
return DraggableMobileActions(
|
|
scale: scale,
|
|
position: draggablePositions.mobileActions,
|
|
width: overlayW,
|
|
height: overlayH,
|
|
onBackPressed: session.inputModel.onMobileBack,
|
|
onHomePressed: session.inputModel.onMobileHome,
|
|
onRecentPressed: session.inputModel.onMobileApps,
|
|
onHidePressed: onHide,
|
|
);
|
|
}
|
|
|
|
return OverlayEntry(builder: (context) {
|
|
if (isDesktop) {
|
|
final c = Provider.of<CanvasModel>(context);
|
|
return makeMobileActions(context, c.scale * 2.0);
|
|
} else {
|
|
return makeMobileActions(globalKey.currentContext!, 1.0);
|
|
}
|
|
});
|
|
}
|
|
|
|
void showToast(String text, {Duration timeout = const Duration(seconds: 3)}) {
|
|
final overlayState = globalKey.currentState?.overlay;
|
|
if (overlayState == null) return;
|
|
final entry = OverlayEntry(builder: (context) {
|
|
return IgnorePointer(
|
|
child: Align(
|
|
alignment: const Alignment(0.0, 0.8),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: MyTheme.color(context).toastBg,
|
|
borderRadius: const BorderRadius.all(
|
|
Radius.circular(20),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
|
|
child: Text(
|
|
text,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
decoration: TextDecoration.none,
|
|
fontWeight: FontWeight.w300,
|
|
fontSize: 18,
|
|
color: MyTheme.color(context).toastText),
|
|
),
|
|
)));
|
|
});
|
|
overlayState.insert(entry);
|
|
Future.delayed(timeout, () {
|
|
entry.remove();
|
|
});
|
|
}
|
|
|
|
// TODO
|
|
// - Remove argument "contentPadding", no need for it, all should look the same.
|
|
// - Remove "required" for argument "content". See simple confirm dialog "delete peer", only title and actions are used. No need to "content: SizedBox.shrink()".
|
|
// - Make dead code alive, transform arguments "onSubmit" and "onCancel" into correspondenting buttons "ConfirmOkButton", "CancelButton".
|
|
class CustomAlertDialog extends StatelessWidget {
|
|
const CustomAlertDialog(
|
|
{Key? key,
|
|
this.title,
|
|
this.titlePadding,
|
|
required this.content,
|
|
this.actions,
|
|
this.contentPadding,
|
|
this.contentBoxConstraints = const BoxConstraints(maxWidth: 500),
|
|
this.onSubmit,
|
|
this.onCancel})
|
|
: super(key: key);
|
|
|
|
final Widget? title;
|
|
final EdgeInsetsGeometry? titlePadding;
|
|
final Widget content;
|
|
final List<Widget>? actions;
|
|
final double? contentPadding;
|
|
final BoxConstraints contentBoxConstraints;
|
|
final Function()? onSubmit;
|
|
final Function()? onCancel;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// request focus
|
|
FocusScopeNode scopeNode = FocusScopeNode();
|
|
Future.delayed(Duration.zero, () {
|
|
if (!scopeNode.hasFocus) scopeNode.requestFocus();
|
|
});
|
|
bool tabTapped = false;
|
|
if (isAndroid) gFFI.invokeMethod("enable_soft_keyboard", true);
|
|
|
|
return FocusScope(
|
|
node: scopeNode,
|
|
autofocus: true,
|
|
onKey: (node, key) {
|
|
if (key.logicalKey == LogicalKeyboardKey.escape) {
|
|
if (key is RawKeyDownEvent) {
|
|
onCancel?.call();
|
|
}
|
|
return KeyEventResult.handled; // avoid TextField exception on escape
|
|
} else if (!tabTapped &&
|
|
onSubmit != null &&
|
|
(key.logicalKey == LogicalKeyboardKey.enter ||
|
|
key.logicalKey == LogicalKeyboardKey.numpadEnter)) {
|
|
if (key is RawKeyDownEvent) onSubmit?.call();
|
|
return KeyEventResult.handled;
|
|
} else if (key.logicalKey == LogicalKeyboardKey.tab) {
|
|
if (key is RawKeyDownEvent) {
|
|
scopeNode.nextFocus();
|
|
tabTapped = true;
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: AlertDialog(
|
|
scrollable: true,
|
|
title: title,
|
|
content: ConstrainedBox(
|
|
constraints: contentBoxConstraints,
|
|
child: content,
|
|
),
|
|
actions: actions,
|
|
titlePadding: titlePadding ?? MyTheme.dialogTitlePadding(),
|
|
contentPadding:
|
|
MyTheme.dialogContentPadding(actions: actions is List),
|
|
actionsPadding: MyTheme.dialogActionsPadding(),
|
|
buttonPadding: MyTheme.dialogButtonPadding),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget createDialogContent(String text) {
|
|
final RegExp linkRegExp = RegExp(r'(https?://[^\s]+)');
|
|
final List<TextSpan> spans = [];
|
|
int start = 0;
|
|
bool hasLink = false;
|
|
|
|
linkRegExp.allMatches(text).forEach((match) {
|
|
hasLink = true;
|
|
if (match.start > start) {
|
|
spans.add(TextSpan(text: text.substring(start, match.start)));
|
|
}
|
|
spans.add(TextSpan(
|
|
text: match.group(0) ?? '',
|
|
style: TextStyle(
|
|
color: Colors.blue,
|
|
decoration: TextDecoration.underline,
|
|
),
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = () {
|
|
String linkText = match.group(0) ?? '';
|
|
linkText = linkText.replaceAll(RegExp(r'[.,;!?]+$'), '');
|
|
launchUrl(Uri.parse(linkText));
|
|
},
|
|
));
|
|
start = match.end;
|
|
});
|
|
|
|
if (start < text.length) {
|
|
spans.add(TextSpan(text: text.substring(start)));
|
|
}
|
|
|
|
if (!hasLink) {
|
|
return SelectableText(text, style: const TextStyle(fontSize: 15));
|
|
}
|
|
|
|
return SelectableText.rich(
|
|
TextSpan(
|
|
style: TextStyle(color: Colors.black, fontSize: 15),
|
|
children: spans,
|
|
),
|
|
);
|
|
}
|
|
|
|
void msgBox(SessionID sessionId, String type, String title, String text,
|
|
String link, OverlayDialogManager dialogManager,
|
|
{bool? hasCancel, ReconnectHandle? reconnect, int? reconnectTimeout}) {
|
|
dialogManager.dismissAll();
|
|
List<Widget> buttons = [];
|
|
bool hasOk = false;
|
|
submit() {
|
|
dialogManager.dismissAll();
|
|
// https://github.com/rustdesk/rustdesk/blob/5e9a31340b899822090a3731769ae79c6bf5f3e5/src/ui/common.tis#L263
|
|
if (!type.contains("custom") && desktopType != DesktopType.portForward) {
|
|
closeConnection();
|
|
}
|
|
}
|
|
|
|
cancel() {
|
|
dialogManager.dismissAll();
|
|
}
|
|
|
|
jumplink() {
|
|
if (link.startsWith('http')) {
|
|
launchUrl(Uri.parse(link));
|
|
}
|
|
}
|
|
|
|
if (type != "connecting" && type != "success" && !type.contains("nook")) {
|
|
hasOk = true;
|
|
buttons.insert(0, dialogButton('OK', onPressed: submit));
|
|
}
|
|
hasCancel ??= !type.contains("error") &&
|
|
!type.contains("nocancel") &&
|
|
type != "restarting";
|
|
if (hasCancel) {
|
|
buttons.insert(
|
|
0, dialogButton('Cancel', onPressed: cancel, isOutline: true));
|
|
}
|
|
if (type.contains("hasclose")) {
|
|
buttons.insert(
|
|
0,
|
|
dialogButton('Close', onPressed: () {
|
|
dialogManager.dismissAll();
|
|
}));
|
|
}
|
|
if (reconnect != null && title == "Connection Error") {
|
|
// `enabled` is used to disable the dialog button once the button is clicked.
|
|
final enabled = true.obs;
|
|
final button = reconnectTimeout != null
|
|
? Obx(() => _ReconnectCountDownButton(
|
|
second: reconnectTimeout,
|
|
onPressed: enabled.isTrue
|
|
? () {
|
|
// Disable the button
|
|
enabled.value = false;
|
|
reconnect(dialogManager, sessionId, false);
|
|
}
|
|
: null,
|
|
))
|
|
: Obx(
|
|
() => dialogButton(
|
|
'Reconnect',
|
|
isOutline: true,
|
|
onPressed: enabled.isTrue
|
|
? () {
|
|
// Disable the button
|
|
enabled.value = false;
|
|
reconnect(dialogManager, sessionId, false);
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
buttons.insert(0, button);
|
|
}
|
|
if (link.isNotEmpty) {
|
|
buttons.insert(0, dialogButton('JumpLink', onPressed: jumplink));
|
|
}
|
|
dialogManager.show(
|
|
(setState, close, context) => CustomAlertDialog(
|
|
title: null,
|
|
content: SelectionArea(child: msgboxContent(type, title, text)),
|
|
actions: buttons,
|
|
onSubmit: hasOk ? submit : null,
|
|
onCancel: hasCancel == true ? cancel : null,
|
|
),
|
|
tag: '$sessionId-$type-$title-$text-$link',
|
|
);
|
|
}
|
|
|
|
Color? _msgboxColor(String type) {
|
|
if (type == "input-password" || type == "custom-os-password") {
|
|
return Color(0xFFAD448E);
|
|
}
|
|
if (type.contains("success")) {
|
|
return Color(0xFF32bea6);
|
|
}
|
|
if (type.contains("error") || type == "re-input-password") {
|
|
return Color(0xFFE04F5F);
|
|
}
|
|
return Color(0xFF2C8CFF);
|
|
}
|
|
|
|
Widget msgboxIcon(String type) {
|
|
IconData? iconData;
|
|
if (type.contains("error") || type == "re-input-password") {
|
|
iconData = Icons.cancel;
|
|
}
|
|
if (type.contains("success")) {
|
|
iconData = Icons.check_circle;
|
|
}
|
|
if (type == "wait-uac" || type == "wait-remote-accept-nook") {
|
|
iconData = Icons.hourglass_top;
|
|
}
|
|
if (type == 'on-uac' || type == 'on-foreground-elevated') {
|
|
iconData = Icons.admin_panel_settings;
|
|
}
|
|
if (type.contains('info')) {
|
|
iconData = Icons.info;
|
|
}
|
|
if (iconData != null) {
|
|
return Icon(iconData, size: 50, color: _msgboxColor(type))
|
|
.marginOnly(right: 16);
|
|
}
|
|
|
|
return Offstage();
|
|
}
|
|
|
|
// title should be null
|
|
Widget msgboxContent(String type, String title, String text) {
|
|
String translateText(String text) {
|
|
if (text.indexOf('Failed') == 0 && text.indexOf(': ') > 0) {
|
|
List<String> words = text.split(': ');
|
|
for (var i = 0; i < words.length; ++i) {
|
|
words[i] = translate(words[i]);
|
|
}
|
|
text = words.join(': ');
|
|
} else {
|
|
List<String> words = text.split(' ');
|
|
if (words.length > 1 && words[0].endsWith('_tip')) {
|
|
words[0] = translate(words[0]);
|
|
final rest = text.substring(words[0].length + 1);
|
|
text = '${words[0]} ${translate(rest)}';
|
|
} else {
|
|
text = translate(text);
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
return Row(
|
|
children: [
|
|
msgboxIcon(type),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
translate(title),
|
|
style: TextStyle(fontSize: 21),
|
|
).marginOnly(bottom: 10),
|
|
createDialogContent(translateText(text)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
).marginOnly(bottom: 12);
|
|
}
|
|
|
|
void msgBoxCommon(OverlayDialogManager dialogManager, String title,
|
|
Widget content, List<Widget> buttons,
|
|
{bool hasCancel = true}) {
|
|
dialogManager.show((setState, close, context) => CustomAlertDialog(
|
|
title: Text(
|
|
translate(title),
|
|
style: TextStyle(fontSize: 21),
|
|
),
|
|
content: content,
|
|
actions: buttons,
|
|
onCancel: hasCancel ? close : null,
|
|
));
|
|
}
|
|
|
|
Color str2color(String str, [alpha = 0xFF]) {
|
|
var hash = 160 << 16 + 114 << 8 + 91;
|
|
for (var i = 0; i < str.length; i += 1) {
|
|
hash = str.codeUnitAt(i) + ((hash << 5) - hash);
|
|
}
|
|
hash = hash % 16777216;
|
|
return Color((hash & 0xFF7FFF) | (alpha << 24));
|
|
}
|
|
|
|
Color str2color2(String str, {List<int> existing = const []}) {
|
|
Map<String, Color> colorMap = {
|
|
"red": Colors.red,
|
|
"green": Colors.green,
|
|
"blue": Colors.blue,
|
|
"orange": Colors.orange,
|
|
"purple": Colors.purple,
|
|
"grey": Colors.grey,
|
|
"cyan": Colors.cyan,
|
|
"lime": Colors.lime,
|
|
"teal": Colors.teal,
|
|
"pink": Colors.pink[200]!,
|
|
"indigo": Colors.indigo,
|
|
"brown": Colors.brown,
|
|
};
|
|
final color = colorMap[str.toLowerCase()];
|
|
if (color != null) {
|
|
return color.withAlpha(0xFF);
|
|
}
|
|
if (str.toLowerCase() == 'yellow') {
|
|
return Colors.yellow.withAlpha(0xFF);
|
|
}
|
|
var hash = 0;
|
|
for (var i = 0; i < str.length; i++) {
|
|
hash += str.codeUnitAt(i);
|
|
}
|
|
List<Color> colorList = colorMap.values.toList();
|
|
hash = hash % colorList.length;
|
|
var result = colorList[hash].withAlpha(0xFF);
|
|
if (existing.contains(result.value)) {
|
|
Color? notUsed =
|
|
colorList.firstWhereOrNull((e) => !existing.contains(e.value));
|
|
if (notUsed != null) {
|
|
result = notUsed;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const K = 1024;
|
|
const M = K * K;
|
|
const G = M * K;
|
|
|
|
String readableFileSize(double size) {
|
|
if (size < K) {
|
|
return "${size.toStringAsFixed(2)} B";
|
|
} else if (size < M) {
|
|
return "${(size / K).toStringAsFixed(2)} KB";
|
|
} else if (size < G) {
|
|
return "${(size / M).toStringAsFixed(2)} MB";
|
|
} else {
|
|
return "${(size / G).toStringAsFixed(2)} GB";
|
|
}
|
|
}
|
|
|
|
/// Flutter can't not catch PointerMoveEvent when size is 1
|
|
/// This will happen in Android AccessibilityService Input
|
|
/// android can't init dispatching size yet ,see: https://stackoverflow.com/questions/59960451/android-accessibility-dispatchgesture-is-it-possible-to-specify-pressure-for-a
|
|
/// use this temporary solution until flutter or android fixes the bug
|
|
class AccessibilityListener extends StatelessWidget {
|
|
final Widget? child;
|
|
static final offset = 100;
|
|
|
|
AccessibilityListener({this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Listener(
|
|
onPointerDown: (evt) {
|
|
if (evt.size == 1) {
|
|
GestureBinding.instance.handlePointerEvent(PointerAddedEvent(
|
|
pointer: evt.pointer + offset, position: evt.position));
|
|
GestureBinding.instance.handlePointerEvent(PointerDownEvent(
|
|
pointer: evt.pointer + offset,
|
|
size: 0.1,
|
|
position: evt.position));
|
|
}
|
|
},
|
|
onPointerUp: (evt) {
|
|
if (evt.size == 1) {
|
|
GestureBinding.instance.handlePointerEvent(PointerUpEvent(
|
|
pointer: evt.pointer + offset,
|
|
size: 0.1,
|
|
position: evt.position));
|
|
GestureBinding.instance.handlePointerEvent(PointerRemovedEvent(
|
|
pointer: evt.pointer + offset, position: evt.position));
|
|
}
|
|
},
|
|
onPointerMove: (evt) {
|
|
if (evt.size == 1) {
|
|
GestureBinding.instance.handlePointerEvent(PointerMoveEvent(
|
|
pointer: evt.pointer + offset,
|
|
size: 0.1,
|
|
delta: evt.delta,
|
|
position: evt.position));
|
|
}
|
|
},
|
|
child: child);
|
|
}
|
|
}
|
|
|
|
class AndroidPermissionManager {
|
|
static Completer<bool>? _completer;
|
|
static Timer? _timer;
|
|
static var _current = "";
|
|
|
|
static bool isWaitingFile() {
|
|
if (_completer != null) {
|
|
return !_completer!.isCompleted && _current == kManageExternalStorage;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static Future<bool> check(String type) {
|
|
if (isDesktop || isWeb) {
|
|
return Future.value(true);
|
|
}
|
|
return gFFI.invokeMethod("check_permission", type);
|
|
}
|
|
|
|
// startActivity goto Android Setting's page to request permission manually by user
|
|
static void startAction(String action) {
|
|
gFFI.invokeMethod(AndroidChannel.kStartAction, action);
|
|
}
|
|
|
|
/// We use XXPermissions to request permissions,
|
|
/// for supported types, see https://github.com/getActivity/XXPermissions/blob/e46caea32a64ad7819df62d448fb1c825481cd28/library/src/main/java/com/hjq/permissions/Permission.java
|
|
static Future<bool> request(String type) {
|
|
if (isDesktop || isWeb) {
|
|
return Future.value(true);
|
|
}
|
|
|
|
gFFI.invokeMethod("request_permission", type);
|
|
|
|
// clear last task
|
|
if (_completer?.isCompleted == false) {
|
|
_completer?.complete(false);
|
|
}
|
|
_timer?.cancel();
|
|
|
|
_current = type;
|
|
_completer = Completer<bool>();
|
|
|
|
_timer = Timer(Duration(seconds: 120), () {
|
|
if (_completer == null) return;
|
|
if (!_completer!.isCompleted) {
|
|
_completer!.complete(false);
|
|
}
|
|
_completer = null;
|
|
_current = "";
|
|
});
|
|
return _completer!.future;
|
|
}
|
|
|
|
static complete(String type, bool res) {
|
|
if (type != _current) {
|
|
res = false;
|
|
}
|
|
_timer?.cancel();
|
|
_completer?.complete(res);
|
|
_current = "";
|
|
}
|
|
}
|
|
|
|
RadioListTile<T> getRadio<T>(
|
|
Widget title, T toValue, T curValue, ValueChanged<T?>? onChange,
|
|
{bool? dense}) {
|
|
return RadioListTile<T>(
|
|
visualDensity: VisualDensity.compact,
|
|
controlAffinity: ListTileControlAffinity.trailing,
|
|
title: title,
|
|
value: toValue,
|
|
groupValue: curValue,
|
|
onChanged: onChange,
|
|
dense: dense,
|
|
);
|
|
}
|
|
|
|
/// find ffi, tag is Remote ID
|
|
/// for session specific usage
|
|
FFI ffi(String? tag) {
|
|
return Get.find<FFI>(tag: tag);
|
|
}
|
|
|
|
/// Global FFI object
|
|
late FFI _globalFFI;
|
|
|
|
FFI get gFFI => _globalFFI;
|
|
|
|
Future<void> initGlobalFFI() async {
|
|
debugPrint("_globalFFI init");
|
|
_globalFFI = FFI(null);
|
|
debugPrint("_globalFFI init end");
|
|
// after `put`, can also be globally found by Get.find<FFI>();
|
|
Get.put<FFI>(_globalFFI, permanent: true);
|
|
}
|
|
|
|
String translate(String name) {
|
|
if (name.startsWith('Failed to') && name.contains(': ')) {
|
|
return name.split(': ').map((x) => translate(x)).join(': ');
|
|
}
|
|
return platformFFI.translate(name, localeName);
|
|
}
|
|
|
|
// This function must be kept the same as the one in rust and sciter code.
|
|
// rust: libs/hbb_common/src/config.rs -> option2bool()
|
|
// sciter: Does not have the function, but it should be kept the same.
|
|
bool option2bool(String option, String value) {
|
|
bool res;
|
|
if (option.startsWith("enable-")) {
|
|
res = value != "N";
|
|
} else if (option.startsWith("allow-") ||
|
|
option == kOptionStopService ||
|
|
option == kOptionDirectServer ||
|
|
option == kOptionForceAlwaysRelay) {
|
|
res = value == "Y";
|
|
} else {
|
|
assert(false);
|
|
res = value != "N";
|
|
}
|
|
return res;
|
|
}
|
|
|
|
String bool2option(String option, bool b) {
|
|
String res;
|
|
if (option.startsWith('enable-')) {
|
|
res = b ? defaultOptionYes : 'N';
|
|
} else if (option.startsWith('allow-') ||
|
|
option == kOptionStopService ||
|
|
option == kOptionDirectServer ||
|
|
option == kOptionForceAlwaysRelay) {
|
|
res = b ? 'Y' : defaultOptionNo;
|
|
} else {
|
|
assert(false);
|
|
res = b ? 'Y' : 'N';
|
|
}
|
|
return res;
|
|
}
|
|
|
|
mainSetBoolOption(String key, bool value) async {
|
|
String v = bool2option(key, value);
|
|
await bind.mainSetOption(key: key, value: v);
|
|
}
|
|
|
|
Future<bool> mainGetBoolOption(String key) async {
|
|
return option2bool(key, await bind.mainGetOption(key: key));
|
|
}
|
|
|
|
bool mainGetBoolOptionSync(String key) {
|
|
return option2bool(key, bind.mainGetOptionSync(key: key));
|
|
}
|
|
|
|
mainSetLocalBoolOption(String key, bool value) async {
|
|
String v = bool2option(key, value);
|
|
await bind.mainSetLocalOption(key: key, value: v);
|
|
}
|
|
|
|
bool mainGetLocalBoolOptionSync(String key) {
|
|
return option2bool(key, bind.mainGetLocalOption(key: key));
|
|
}
|
|
|
|
bool mainGetPeerBoolOptionSync(String id, String key) {
|
|
return option2bool(key, bind.mainGetPeerOptionSync(id: id, key: key));
|
|
}
|
|
|
|
// Don't use `option2bool()` and `bool2option()` to convert the session option.
|
|
// Use `sessionGetToggleOption()` and `sessionToggleOption()` instead.
|
|
// Because all session options use `Y` and `<Empty>` as values.
|
|
|
|
Future<bool> matchPeer(String searchText, Peer peer) async {
|
|
if (searchText.isEmpty) {
|
|
return true;
|
|
}
|
|
if (peer.id.toLowerCase().contains(searchText)) {
|
|
return true;
|
|
}
|
|
if (peer.hostname.toLowerCase().contains(searchText) ||
|
|
peer.username.toLowerCase().contains(searchText)) {
|
|
return true;
|
|
}
|
|
final alias = peer.alias;
|
|
if (alias.isEmpty) {
|
|
return false;
|
|
}
|
|
return alias.toLowerCase().contains(searchText);
|
|
}
|
|
|
|
/// Get the image for the current [platform].
|
|
Widget getPlatformImage(String platform, {double size = 50}) {
|
|
if (platform.isEmpty) {
|
|
return Container(width: size, height: size);
|
|
}
|
|
if (platform == kPeerPlatformMacOS) {
|
|
platform = 'mac';
|
|
} else if (platform != kPeerPlatformLinux &&
|
|
platform != kPeerPlatformAndroid) {
|
|
platform = 'win';
|
|
} else {
|
|
platform = platform.toLowerCase();
|
|
}
|
|
return SvgPicture.asset('assets/$platform.svg', height: size, width: size);
|
|
}
|
|
|
|
class OffsetDevicePixelRatio {
|
|
Offset offset;
|
|
final double devicePixelRatio;
|
|
OffsetDevicePixelRatio(this.offset, this.devicePixelRatio);
|
|
}
|
|
|
|
class LastWindowPosition {
|
|
double? width;
|
|
double? height;
|
|
double? offsetWidth;
|
|
double? offsetHeight;
|
|
bool? isMaximized;
|
|
bool? isFullscreen;
|
|
|
|
LastWindowPosition(this.width, this.height, this.offsetWidth,
|
|
this.offsetHeight, this.isMaximized, this.isFullscreen);
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return <String, dynamic>{
|
|
"width": width,
|
|
"height": height,
|
|
"offsetWidth": offsetWidth,
|
|
"offsetHeight": offsetHeight,
|
|
"isMaximized": isMaximized,
|
|
"isFullscreen": isFullscreen,
|
|
};
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return jsonEncode(toJson());
|
|
}
|
|
|
|
static LastWindowPosition? loadFromString(String content) {
|
|
if (content.isEmpty) {
|
|
return null;
|
|
}
|
|
try {
|
|
final m = jsonDecode(content);
|
|
return LastWindowPosition(m["width"], m["height"], m["offsetWidth"],
|
|
m["offsetHeight"], m["isMaximized"], m["isFullscreen"]);
|
|
} catch (e) {
|
|
debugPrintStack(
|
|
label:
|
|
'Failed to load LastWindowPosition "$content" ${e.toString()}');
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
String get windowFramePrefix =>
|
|
kWindowPrefix +
|
|
(bind.isIncomingOnly()
|
|
? "incoming_"
|
|
: (bind.isOutgoingOnly() ? "outgoing_" : ""));
|
|
|
|
/// Save window position and size on exit
|
|
/// Note that windowId must be provided if it's subwindow
|
|
Future<void> saveWindowPosition(WindowType type, {int? windowId}) async {
|
|
if (type != WindowType.Main && windowId == null) {
|
|
debugPrint(
|
|
"Error: windowId cannot be null when saving positions for sub window");
|
|
}
|
|
|
|
late Offset position;
|
|
late Size sz;
|
|
late bool isMaximized;
|
|
bool isFullscreen = stateGlobal.fullscreen.isTrue;
|
|
setPreFrame() {
|
|
final pos = bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
|
|
var lpos = LastWindowPosition.loadFromString(pos);
|
|
position = Offset(
|
|
lpos?.offsetWidth ?? position.dx, lpos?.offsetHeight ?? position.dy);
|
|
sz = Size(lpos?.width ?? sz.width, lpos?.height ?? sz.height);
|
|
}
|
|
|
|
switch (type) {
|
|
case WindowType.Main:
|
|
// Checking `bind.isIncomingOnly()` is a simple workaround for MacOS.
|
|
// `await windowManager.isMaximized()` will always return true
|
|
// if is not resizable. The reason is unknown.
|
|
//
|
|
// `setResizable(!bind.isIncomingOnly());` in main.dart
|
|
isMaximized =
|
|
bind.isIncomingOnly() ? false : await windowManager.isMaximized();
|
|
if (isFullscreen || isMaximized) {
|
|
setPreFrame();
|
|
} else {
|
|
position = await windowManager.getPosition();
|
|
sz = await windowManager.getSize();
|
|
}
|
|
break;
|
|
default:
|
|
final wc = WindowController.fromWindowId(windowId!);
|
|
isMaximized = await wc.isMaximized();
|
|
if (isFullscreen || isMaximized) {
|
|
setPreFrame();
|
|
} else {
|
|
final Rect frame;
|
|
try {
|
|
frame = await wc.getFrame();
|
|
} catch (e) {
|
|
debugPrint(
|
|
"Failed to get frame of window $windowId, it may be hidden");
|
|
return;
|
|
}
|
|
position = frame.topLeft;
|
|
sz = frame.size;
|
|
}
|
|
break;
|
|
}
|
|
if (isWindows) {
|
|
const kMinOffset = -10000;
|
|
const kMaxOffset = 10000;
|
|
if (position.dx < kMinOffset ||
|
|
position.dy < kMinOffset ||
|
|
position.dx > kMaxOffset ||
|
|
position.dy > kMaxOffset) {
|
|
debugPrint("Invalid position: $position, ignore saving position");
|
|
return;
|
|
}
|
|
}
|
|
|
|
final pos = LastWindowPosition(
|
|
sz.width, sz.height, position.dx, position.dy, isMaximized, isFullscreen);
|
|
debugPrint(
|
|
"Saving frame: $windowId: ${pos.width}/${pos.height}, offset:${pos.offsetWidth}/${pos.offsetHeight}, isMaximized:${pos.isMaximized}, isFullscreen:${pos.isFullscreen}");
|
|
|
|
await bind.setLocalFlutterOption(
|
|
k: windowFramePrefix + type.name, v: pos.toString());
|
|
|
|
if (type == WindowType.RemoteDesktop && windowId != null) {
|
|
await _saveSessionWindowPosition(
|
|
type, windowId, isMaximized, isFullscreen, pos);
|
|
}
|
|
}
|
|
|
|
Future _saveSessionWindowPosition(WindowType windowType, int windowId,
|
|
bool isMaximized, bool isFullscreen, LastWindowPosition pos) async {
|
|
final remoteList = await DesktopMultiWindow.invokeMethod(
|
|
windowId, kWindowEventGetRemoteList, null);
|
|
getPeerPos(String peerId) {
|
|
if (isMaximized || isFullscreen) {
|
|
final peerPos = bind.mainGetPeerFlutterOptionSync(
|
|
id: peerId, k: windowFramePrefix + windowType.name);
|
|
var lpos = LastWindowPosition.loadFromString(peerPos);
|
|
return LastWindowPosition(
|
|
lpos?.width ?? pos.offsetWidth,
|
|
lpos?.height ?? pos.offsetHeight,
|
|
lpos?.offsetWidth ?? pos.offsetWidth,
|
|
lpos?.offsetHeight ?? pos.offsetHeight,
|
|
isMaximized,
|
|
isFullscreen)
|
|
.toString();
|
|
} else {
|
|
return pos.toString();
|
|
}
|
|
}
|
|
|
|
if (remoteList != null) {
|
|
for (final peerId in remoteList.split(',')) {
|
|
bind.mainSetPeerFlutterOptionSync(
|
|
id: peerId,
|
|
k: windowFramePrefix + windowType.name,
|
|
v: getPeerPos(peerId));
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Size> _adjustRestoreMainWindowSize(double? width, double? height) async {
|
|
const double minWidth = 1;
|
|
const double minHeight = 1;
|
|
const double maxWidth = 6480;
|
|
const double maxHeight = 6480;
|
|
|
|
final defaultWidth =
|
|
((isDesktop || isWebDesktop) ? 1280 : kMobileDefaultDisplayWidth)
|
|
.toDouble();
|
|
final defaultHeight =
|
|
((isDesktop || isWebDesktop) ? 720 : kMobileDefaultDisplayHeight)
|
|
.toDouble();
|
|
double restoreWidth = width ?? defaultWidth;
|
|
double restoreHeight = height ?? defaultHeight;
|
|
|
|
if (restoreWidth < minWidth) {
|
|
restoreWidth = defaultWidth;
|
|
}
|
|
if (restoreHeight < minHeight) {
|
|
restoreHeight = defaultHeight;
|
|
}
|
|
if (restoreWidth > maxWidth) {
|
|
restoreWidth = defaultWidth;
|
|
}
|
|
if (restoreHeight > maxHeight) {
|
|
restoreHeight = defaultHeight;
|
|
}
|
|
return Size(restoreWidth, restoreHeight);
|
|
}
|
|
|
|
bool isPointInRect(Offset point, Rect rect) {
|
|
return point.dx >= rect.left &&
|
|
point.dx <= rect.right &&
|
|
point.dy >= rect.top &&
|
|
point.dy <= rect.bottom;
|
|
}
|
|
|
|
/// return null means center
|
|
Future<OffsetDevicePixelRatio?> _adjustRestoreMainWindowOffset(
|
|
double? left,
|
|
double? top,
|
|
double? width,
|
|
double? height,
|
|
) async {
|
|
if (left == null || top == null || width == null || height == null) {
|
|
return null;
|
|
}
|
|
|
|
double? frameLeft;
|
|
double? frameTop;
|
|
double? frameRight;
|
|
double? frameBottom;
|
|
double devicePixelRatio = 1.0;
|
|
|
|
if (isDesktop || isWebDesktop) {
|
|
for (final screen in await window_size.getScreenList()) {
|
|
if (isPointInRect(Offset(left, top), screen.visibleFrame)) {
|
|
devicePixelRatio = screen.scaleFactor;
|
|
}
|
|
frameLeft = frameLeft == null
|
|
? screen.visibleFrame.left
|
|
: min(screen.visibleFrame.left, frameLeft);
|
|
frameTop = frameTop == null
|
|
? screen.visibleFrame.top
|
|
: min(screen.visibleFrame.top, frameTop);
|
|
frameRight = frameRight == null
|
|
? screen.visibleFrame.right
|
|
: max(screen.visibleFrame.right, frameRight);
|
|
frameBottom = frameBottom == null
|
|
? screen.visibleFrame.bottom
|
|
: max(screen.visibleFrame.bottom, frameBottom);
|
|
}
|
|
}
|
|
if (frameLeft == null) {
|
|
frameLeft = 0.0;
|
|
frameTop = 0.0;
|
|
frameRight = ((isDesktop || isWebDesktop)
|
|
? kDesktopMaxDisplaySize
|
|
: kMobileMaxDisplaySize)
|
|
.toDouble();
|
|
frameBottom = ((isDesktop || isWebDesktop)
|
|
? kDesktopMaxDisplaySize
|
|
: kMobileMaxDisplaySize)
|
|
.toDouble();
|
|
}
|
|
final minWidth = 10.0;
|
|
if ((left + minWidth) > frameRight! ||
|
|
(top + minWidth) > frameBottom! ||
|
|
(left + width - minWidth) < frameLeft ||
|
|
top < frameTop!) {
|
|
return null;
|
|
} else {
|
|
return OffsetDevicePixelRatio(Offset(left, top), devicePixelRatio);
|
|
}
|
|
}
|
|
|
|
/// Restore window position and size on start
|
|
/// Note that windowId must be provided if it's subwindow
|
|
//
|
|
// display is used to set the offset of the window in individual display mode.
|
|
Future<bool> restoreWindowPosition(WindowType type,
|
|
{int? windowId, String? peerId, int? display}) async {
|
|
if (bind
|
|
.mainGetEnv(key: "DISABLE_RUSTDESK_RESTORE_WINDOW_POSITION")
|
|
.isNotEmpty) {
|
|
return false;
|
|
}
|
|
if (type != WindowType.Main && windowId == null) {
|
|
debugPrint(
|
|
"Error: windowId cannot be null when saving positions for sub window");
|
|
return false;
|
|
}
|
|
|
|
bool isRemotePeerPos = false;
|
|
String? pos;
|
|
// No need to check mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs)
|
|
// Though "open in tabs" is true and the new window restore peer position, it's ok.
|
|
if (type == WindowType.RemoteDesktop && windowId != null && peerId != null) {
|
|
final peerPos = bind.mainGetPeerFlutterOptionSync(
|
|
id: peerId, k: windowFramePrefix + type.name);
|
|
if (peerPos.isNotEmpty) {
|
|
pos = peerPos;
|
|
}
|
|
isRemotePeerPos = pos != null;
|
|
}
|
|
pos ??= bind.getLocalFlutterOption(k: windowFramePrefix + type.name);
|
|
|
|
var lpos = LastWindowPosition.loadFromString(pos);
|
|
if (lpos == null) {
|
|
debugPrint("no window position saved, ignoring position restoration");
|
|
return false;
|
|
}
|
|
if (type == WindowType.RemoteDesktop) {
|
|
if (!isRemotePeerPos && windowId != null) {
|
|
if (lpos.offsetWidth != null) {
|
|
lpos.offsetWidth = lpos.offsetWidth! + windowId * kNewWindowOffset;
|
|
}
|
|
if (lpos.offsetHeight != null) {
|
|
lpos.offsetHeight = lpos.offsetHeight! + windowId * kNewWindowOffset;
|
|
}
|
|
}
|
|
if (display != null) {
|
|
if (lpos.offsetWidth != null) {
|
|
lpos.offsetWidth = lpos.offsetWidth! + display * kNewWindowOffset;
|
|
}
|
|
if (lpos.offsetHeight != null) {
|
|
lpos.offsetHeight = lpos.offsetHeight! + display * kNewWindowOffset;
|
|
}
|
|
}
|
|
}
|
|
|
|
final size = await _adjustRestoreMainWindowSize(lpos.width, lpos.height);
|
|
final offsetDevicePixelRatio = await _adjustRestoreMainWindowOffset(
|
|
lpos.offsetWidth,
|
|
lpos.offsetHeight,
|
|
size.width,
|
|
size.height,
|
|
);
|
|
debugPrint(
|
|
"restore lpos: ${size.width}/${size.height}, offset:${offsetDevicePixelRatio?.offset.dx}/${offsetDevicePixelRatio?.offset.dy}, devicePixelRatio:${offsetDevicePixelRatio?.devicePixelRatio}, isMaximized: ${lpos.isMaximized}, isFullscreen: ${lpos.isFullscreen}");
|
|
|
|
switch (type) {
|
|
case WindowType.Main:
|
|
// https://github.com/rustdesk/rustdesk/issues/8038
|
|
// `setBounds()` in `window_manager` will use the current devicePixelRatio.
|
|
// So we need to adjust the offset by the scale factor.
|
|
// https://github.com/rustdesk-org/window_manager/blob/f19acdb008645366339444a359a45c3257c8b32e/windows/window_manager.cpp#L701
|
|
if (isWindows) {
|
|
double? curDevicePixelRatio;
|
|
Offset curPos = await windowManager.getPosition();
|
|
for (final screen in await window_size.getScreenList()) {
|
|
if (isPointInRect(curPos, screen.visibleFrame)) {
|
|
curDevicePixelRatio = screen.scaleFactor;
|
|
}
|
|
}
|
|
if (curDevicePixelRatio != null &&
|
|
curDevicePixelRatio != 0 &&
|
|
offsetDevicePixelRatio != null) {
|
|
if (offsetDevicePixelRatio.devicePixelRatio != 0) {
|
|
final scale =
|
|
offsetDevicePixelRatio.devicePixelRatio / curDevicePixelRatio;
|
|
offsetDevicePixelRatio.offset =
|
|
offsetDevicePixelRatio.offset.scale(scale, scale);
|
|
debugPrint(
|
|
"restore new offset: ${offsetDevicePixelRatio.offset.dx}/${offsetDevicePixelRatio.offset.dy}, scale:$scale");
|
|
}
|
|
}
|
|
}
|
|
restorePos() async {
|
|
if (offsetDevicePixelRatio == null) {
|
|
await windowManager.center();
|
|
} else {
|
|
await windowManager.setPosition(offsetDevicePixelRatio.offset);
|
|
}
|
|
}
|
|
if (lpos.isMaximized == true) {
|
|
await restorePos();
|
|
if (!(bind.isIncomingOnly() || bind.isOutgoingOnly())) {
|
|
await windowManager.maximize();
|
|
}
|
|
} else {
|
|
if (!bind.isIncomingOnly() || bind.isOutgoingOnly()) {
|
|
await windowManager.setSize(size);
|
|
}
|
|
await restorePos();
|
|
}
|
|
return true;
|
|
default:
|
|
final wc = WindowController.fromWindowId(windowId!);
|
|
restoreFrame() async {
|
|
if (offsetDevicePixelRatio == null) {
|
|
await wc.center();
|
|
} else {
|
|
final frame = Rect.fromLTWH(offsetDevicePixelRatio.offset.dx,
|
|
offsetDevicePixelRatio.offset.dy, size.width, size.height);
|
|
await wc.setFrame(frame);
|
|
}
|
|
}
|
|
if (lpos.isFullscreen == true) {
|
|
if (!isMacOS) {
|
|
await restoreFrame();
|
|
}
|
|
// An duration is needed to avoid the window being restored after fullscreen.
|
|
Future.delayed(Duration(milliseconds: 300), () async {
|
|
if (kWindowId == windowId) {
|
|
stateGlobal.setFullscreen(true);
|
|
} else {
|
|
// If is not current window, we need to send a fullscreen message to `windowId`
|
|
DesktopMultiWindow.invokeMethod(
|
|
windowId, kWindowEventSetFullscreen, 'true');
|
|
}
|
|
});
|
|
} else if (lpos.isMaximized == true) {
|
|
await restoreFrame();
|
|
// An duration is needed to avoid the window being restored after maximized.
|
|
Future.delayed(Duration(milliseconds: 300), () async {
|
|
await wc.maximize();
|
|
});
|
|
} else {
|
|
await restoreFrame();
|
|
}
|
|
break;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Initialize uni links for macos/windows
|
|
///
|
|
/// [Availability]
|
|
/// initUniLinks should only be used on macos/windows.
|
|
/// we use dbus for linux currently.
|
|
Future<bool> initUniLinks() async {
|
|
if (isLinux) {
|
|
return false;
|
|
}
|
|
// check cold boot
|
|
try {
|
|
final initialLink = await getInitialLink();
|
|
print("initialLink: $initialLink");
|
|
if (initialLink == null || initialLink.isEmpty) {
|
|
return false;
|
|
}
|
|
return handleUriLink(uriString: initialLink);
|
|
} catch (err) {
|
|
debugPrintStack(label: "$err");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Listen for uni links.
|
|
///
|
|
/// * handleByFlutter: Should uni links be handled by Flutter.
|
|
///
|
|
/// Returns a [StreamSubscription] which can listen the uni links.
|
|
StreamSubscription? listenUniLinks({handleByFlutter = true}) {
|
|
if (isLinux) {
|
|
return null;
|
|
}
|
|
|
|
final sub = uriLinkStream.listen((Uri? uri) {
|
|
debugPrint("A uri was received: $uri. handleByFlutter $handleByFlutter");
|
|
if (uri != null) {
|
|
if (handleByFlutter) {
|
|
handleUriLink(uri: uri);
|
|
} else {
|
|
bind.sendUrlScheme(url: uri.toString());
|
|
}
|
|
} else {
|
|
print("uni listen error: uri is empty.");
|
|
}
|
|
}, onError: (err) {
|
|
print("uni links error: $err");
|
|
});
|
|
return sub;
|
|
}
|
|
|
|
enum UriLinkType {
|
|
remoteDesktop,
|
|
fileTransfer,
|
|
portForward,
|
|
rdp,
|
|
}
|
|
|
|
// uri link handler
|
|
bool handleUriLink({List<String>? cmdArgs, Uri? uri, String? uriString}) {
|
|
List<String>? args;
|
|
if (cmdArgs != null && cmdArgs.isNotEmpty) {
|
|
args = cmdArgs;
|
|
// rustdesk <uri link>
|
|
if (args[0].startsWith(bind.mainUriPrefixSync())) {
|
|
final uri = Uri.tryParse(args[0]);
|
|
if (uri != null) {
|
|
args = urlLinkToCmdArgs(uri);
|
|
}
|
|
}
|
|
} else if (uri != null) {
|
|
args = urlLinkToCmdArgs(uri);
|
|
} else if (uriString != null) {
|
|
final uri = Uri.tryParse(uriString);
|
|
if (uri != null) {
|
|
args = urlLinkToCmdArgs(uri);
|
|
}
|
|
}
|
|
if (args == null) {
|
|
return false;
|
|
}
|
|
|
|
if (args.isEmpty) {
|
|
windowOnTop(null);
|
|
return true;
|
|
}
|
|
|
|
UriLinkType? type;
|
|
String? id;
|
|
String? password;
|
|
String? switchUuid;
|
|
bool? forceRelay;
|
|
for (int i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case '--connect':
|
|
case '--play':
|
|
type = UriLinkType.remoteDesktop;
|
|
id = args[i + 1];
|
|
i++;
|
|
break;
|
|
case '--file-transfer':
|
|
type = UriLinkType.fileTransfer;
|
|
id = args[i + 1];
|
|
i++;
|
|
break;
|
|
case '--port-forward':
|
|
type = UriLinkType.portForward;
|
|
id = args[i + 1];
|
|
i++;
|
|
break;
|
|
case '--rdp':
|
|
type = UriLinkType.rdp;
|
|
id = args[i + 1];
|
|
i++;
|
|
break;
|
|
case '--password':
|
|
password = args[i + 1];
|
|
i++;
|
|
break;
|
|
case '--switch_uuid':
|
|
switchUuid = args[i + 1];
|
|
i++;
|
|
break;
|
|
case '--relay':
|
|
forceRelay = true;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
if (type != null && id != null) {
|
|
switch (type) {
|
|
case UriLinkType.remoteDesktop:
|
|
Future.delayed(Duration.zero, () {
|
|
rustDeskWinManager.newRemoteDesktop(id!,
|
|
password: password,
|
|
switchUuid: switchUuid,
|
|
forceRelay: forceRelay);
|
|
});
|
|
break;
|
|
case UriLinkType.fileTransfer:
|
|
Future.delayed(Duration.zero, () {
|
|
rustDeskWinManager.newFileTransfer(id!,
|
|
password: password, forceRelay: forceRelay);
|
|
});
|
|
break;
|
|
case UriLinkType.portForward:
|
|
Future.delayed(Duration.zero, () {
|
|
rustDeskWinManager.newPortForward(id!, false,
|
|
password: password, forceRelay: forceRelay);
|
|
});
|
|
break;
|
|
case UriLinkType.rdp:
|
|
Future.delayed(Duration.zero, () {
|
|
rustDeskWinManager.newPortForward(id!, true,
|
|
password: password, forceRelay: forceRelay);
|
|
});
|
|
break;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
List<String>? urlLinkToCmdArgs(Uri uri) {
|
|
String? command;
|
|
String? id;
|
|
final options = ["connect", "play", "file-transfer", "port-forward", "rdp"];
|
|
if (uri.authority.isEmpty &&
|
|
uri.path.split('').every((char) => char == '/')) {
|
|
return [];
|
|
} else if (uri.authority == "connection" && uri.path.startsWith("/new/")) {
|
|
// For compatibility
|
|
command = '--connect';
|
|
id = uri.path.substring("/new/".length);
|
|
} else if (uri.authority == "config") {
|
|
if (isAndroid || isIOS) {
|
|
final config = uri.path.substring("/".length);
|
|
// add a timer to make showToast work
|
|
Timer(Duration(seconds: 1), () {
|
|
importConfig(null, null, config);
|
|
});
|
|
}
|
|
return null;
|
|
} else if (uri.authority == "password") {
|
|
if (isAndroid || isIOS) {
|
|
final password = uri.path.substring("/".length);
|
|
if (password.isNotEmpty) {
|
|
Timer(Duration(seconds: 1), () async {
|
|
await bind.mainSetPermanentPassword(password: password);
|
|
showToast(translate('Successful'));
|
|
});
|
|
}
|
|
}
|
|
} else if (options.contains(uri.authority)) {
|
|
final optionIndex = options.indexOf(uri.authority);
|
|
command = '--${uri.authority}';
|
|
if (uri.path.length > 1) {
|
|
id = uri.path.substring(1);
|
|
}
|
|
if (isMobile && id != null) {
|
|
if (optionIndex == 0 || optionIndex == 1) {
|
|
connect(Get.context!, id);
|
|
} else if (optionIndex == 2) {
|
|
connect(Get.context!, id, isFileTransfer: true);
|
|
}
|
|
return null;
|
|
}
|
|
} else if (uri.authority.length > 2 &&
|
|
(uri.path.length <= 1 ||
|
|
(uri.path == '/r' || uri.path.startsWith('/r@')))) {
|
|
// rustdesk://<connect-id>
|
|
// rustdesk://<connect-id>/r
|
|
// rustdesk://<connect-id>/r@<server>
|
|
command = '--connect';
|
|
id = uri.authority;
|
|
if (uri.path.length > 1) {
|
|
id = id + uri.path;
|
|
}
|
|
}
|
|
|
|
var key = uri.queryParameters["key"];
|
|
if (id != null) {
|
|
if (key != null) {
|
|
id = "$id?key=$key";
|
|
}
|
|
}
|
|
|
|
if (isMobile) {
|
|
if (id != null) {
|
|
final forceRelay = uri.queryParameters["relay"] != null;
|
|
connect(Get.context!, id, forceRelay: forceRelay);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
List<String> args = List.empty(growable: true);
|
|
if (command != null && id != null) {
|
|
args.add(command);
|
|
args.add(id);
|
|
var param = uri.queryParameters;
|
|
String? password = param["password"];
|
|
if (password != null) args.addAll(['--password', password]);
|
|
String? switch_uuid = param["switch_uuid"];
|
|
if (switch_uuid != null) args.addAll(['--switch_uuid', switch_uuid]);
|
|
if (param["relay"] != null) args.add("--relay");
|
|
return args;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
connectMainDesktop(String id,
|
|
{required bool isFileTransfer,
|
|
required bool isTcpTunneling,
|
|
required bool isRDP,
|
|
bool? forceRelay,
|
|
String? password,
|
|
bool? isSharedPassword}) async {
|
|
if (isFileTransfer) {
|
|
await rustDeskWinManager.newFileTransfer(id,
|
|
password: password,
|
|
isSharedPassword: isSharedPassword,
|
|
forceRelay: forceRelay);
|
|
} else if (isTcpTunneling || isRDP) {
|
|
await rustDeskWinManager.newPortForward(id, isRDP,
|
|
password: password,
|
|
isSharedPassword: isSharedPassword,
|
|
forceRelay: forceRelay);
|
|
} else {
|
|
await rustDeskWinManager.newRemoteDesktop(id,
|
|
password: password,
|
|
isSharedPassword: isSharedPassword,
|
|
forceRelay: forceRelay);
|
|
}
|
|
}
|
|
|
|
/// Connect to a peer with [id].
|
|
/// If [isFileTransfer], starts a session only for file transfer.
|
|
/// If [isTcpTunneling], starts a session only for tcp tunneling.
|
|
/// If [isRDP], starts a session only for rdp.
|
|
connect(BuildContext context, String id,
|
|
{bool isFileTransfer = false,
|
|
bool isTcpTunneling = false,
|
|
bool isRDP = false,
|
|
bool forceRelay = false,
|
|
String? password,
|
|
bool? isSharedPassword}) async {
|
|
if (id == '') return;
|
|
if (!isDesktop || desktopType == DesktopType.main) {
|
|
try {
|
|
if (Get.isRegistered<IDTextEditingController>()) {
|
|
final idController = Get.find<IDTextEditingController>();
|
|
idController.text = formatID(id);
|
|
}
|
|
if (Get.isRegistered<TextEditingController>()) {
|
|
final fieldTextEditingController = Get.find<TextEditingController>();
|
|
fieldTextEditingController.text = formatID(id);
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
id = id.replaceAll(' ', '');
|
|
final oldId = id;
|
|
id = await bind.mainHandleRelayId(id: id);
|
|
final forceRelay2 = id != oldId || forceRelay;
|
|
assert(!(isFileTransfer && isTcpTunneling && isRDP),
|
|
"more than one connect type");
|
|
|
|
if (isDesktop) {
|
|
if (desktopType == DesktopType.main) {
|
|
await connectMainDesktop(
|
|
id,
|
|
isFileTransfer: isFileTransfer,
|
|
isTcpTunneling: isTcpTunneling,
|
|
isRDP: isRDP,
|
|
password: password,
|
|
isSharedPassword: isSharedPassword,
|
|
forceRelay: forceRelay2,
|
|
);
|
|
} else {
|
|
await rustDeskWinManager.call(WindowType.Main, kWindowConnect, {
|
|
'id': id,
|
|
'isFileTransfer': isFileTransfer,
|
|
'isTcpTunneling': isTcpTunneling,
|
|
'isRDP': isRDP,
|
|
'password': password,
|
|
'isSharedPassword': isSharedPassword,
|
|
'forceRelay': forceRelay,
|
|
});
|
|
}
|
|
} else {
|
|
if (isFileTransfer) {
|
|
if (!await AndroidPermissionManager.check(kManageExternalStorage)) {
|
|
if (!await AndroidPermissionManager.request(kManageExternalStorage)) {
|
|
return;
|
|
}
|
|
}
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (BuildContext context) => FileManagerPage(
|
|
id: id, password: password, isSharedPassword: isSharedPassword),
|
|
),
|
|
);
|
|
} else {
|
|
if (isWebDesktop) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (BuildContext context) => desktop_remote.RemotePage(
|
|
key: ValueKey(id),
|
|
id: id,
|
|
toolbarState: ToolbarState(),
|
|
password: password,
|
|
forceRelay: forceRelay,
|
|
isSharedPassword: isSharedPassword,
|
|
),
|
|
),
|
|
);
|
|
} else {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (BuildContext context) => RemotePage(
|
|
id: id, password: password, isSharedPassword: isSharedPassword),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
FocusScopeNode currentFocus = FocusScope.of(context);
|
|
if (!currentFocus.hasPrimaryFocus) {
|
|
currentFocus.unfocus();
|
|
}
|
|
}
|
|
|
|
Map<String, String> getHttpHeaders() {
|
|
return {
|
|
'Authorization': 'Bearer ${bind.mainGetLocalOption(key: 'access_token')}'
|
|
};
|
|
}
|
|
|
|
// Simple wrapper of built-in types for reference use.
|
|
class SimpleWrapper<T> {
|
|
T value;
|
|
SimpleWrapper(this.value);
|
|
}
|
|
|
|
/// call this to reload current window.
|
|
///
|
|
/// [Note]
|
|
/// Must have [RefreshWrapper] on the top of widget tree.
|
|
void reloadCurrentWindow() {
|
|
if (Get.context != null) {
|
|
// reload self window
|
|
RefreshWrapper.of(Get.context!)?.rebuild();
|
|
} else {
|
|
debugPrint(
|
|
"reload current window failed, global BuildContext does not exist");
|
|
}
|
|
}
|
|
|
|
/// call this to reload all windows, including main + all sub windows.
|
|
Future<void> reloadAllWindows() async {
|
|
reloadCurrentWindow();
|
|
try {
|
|
final ids = await DesktopMultiWindow.getAllSubWindowIds();
|
|
for (final id in ids) {
|
|
DesktopMultiWindow.invokeMethod(id, kWindowActionRebuild);
|
|
}
|
|
} on AssertionError {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/// Indicate the flutter app is running in portable mode.
|
|
///
|
|
/// [Note]
|
|
/// Portable build is only available on Windows.
|
|
bool isRunningInPortableMode() {
|
|
if (!isWindows) {
|
|
return false;
|
|
}
|
|
return bool.hasEnvironment(kEnvPortableExecutable);
|
|
}
|
|
|
|
/// Window status callback
|
|
Future<void> onActiveWindowChanged() async {
|
|
print(
|
|
"[MultiWindowHandler] active window changed: ${rustDeskWinManager.getActiveWindows()}");
|
|
if (rustDeskWinManager.getActiveWindows().isEmpty) {
|
|
// close all sub windows
|
|
try {
|
|
if (isLinux) {
|
|
await Future.wait([
|
|
saveWindowPosition(WindowType.Main),
|
|
rustDeskWinManager.closeAllSubWindows()
|
|
]);
|
|
} else {
|
|
await rustDeskWinManager.closeAllSubWindows();
|
|
}
|
|
} catch (err) {
|
|
debugPrintStack(label: "$err");
|
|
} finally {
|
|
debugPrint("Start closing RustDesk...");
|
|
await windowManager.setPreventClose(false);
|
|
await windowManager.close();
|
|
if (isMacOS) {
|
|
// If we call without delay, `flutter/macos/Runner/MainFlutterWindow.swift` can handle the "terminate" event.
|
|
// But the app will not close.
|
|
//
|
|
// No idea why we need to delay here, `terminate()` itself is also an async function.
|
|
Future.delayed(Duration.zero, () {
|
|
RdPlatformChannel.instance.terminate();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer periodic_immediate(Duration duration, Future<void> Function() callback) {
|
|
Future.delayed(Duration.zero, callback);
|
|
return Timer.periodic(duration, (timer) async {
|
|
await callback();
|
|
});
|
|
}
|
|
|
|
/// return a human readable windows version
|
|
WindowsTarget getWindowsTarget(int buildNumber) {
|
|
if (!isWindows) {
|
|
return WindowsTarget.naw;
|
|
}
|
|
if (buildNumber >= 22000) {
|
|
return WindowsTarget.w11;
|
|
} else if (buildNumber >= 10240) {
|
|
return WindowsTarget.w10;
|
|
} else if (buildNumber >= 9600) {
|
|
return WindowsTarget.w8_1;
|
|
} else if (buildNumber >= 9200) {
|
|
return WindowsTarget.w8;
|
|
} else if (buildNumber >= 7601) {
|
|
return WindowsTarget.w7;
|
|
} else if (buildNumber >= 6002) {
|
|
return WindowsTarget.vista;
|
|
} else {
|
|
// minimum support
|
|
return WindowsTarget.xp;
|
|
}
|
|
}
|
|
|
|
/// Get windows target build number.
|
|
///
|
|
/// [Note]
|
|
/// Please use this function wrapped with `Platform.isWindows`.
|
|
int getWindowsTargetBuildNumber() {
|
|
return getWindowsTargetBuildNumber_();
|
|
}
|
|
|
|
/// Indicating we need to use compatible ui mode.
|
|
///
|
|
/// [Conditions]
|
|
/// - Windows 7, window will overflow when we use frameless ui.
|
|
bool get kUseCompatibleUiMode =>
|
|
isWindows &&
|
|
const [WindowsTarget.w7].contains(windowsBuildNumber.windowsVersion);
|
|
|
|
class ServerConfig {
|
|
late String idServer;
|
|
late String relayServer;
|
|
late String apiServer;
|
|
late String key;
|
|
|
|
ServerConfig(
|
|
{String? idServer, String? relayServer, String? apiServer, String? key}) {
|
|
this.idServer = idServer?.trim() ?? '';
|
|
this.relayServer = relayServer?.trim() ?? '';
|
|
this.apiServer = apiServer?.trim() ?? '';
|
|
this.key = key?.trim() ?? '';
|
|
}
|
|
|
|
/// decode from shared string (from user shared or rustdesk-server generated)
|
|
/// also see [encode]
|
|
/// throw when decoding failure
|
|
ServerConfig.decode(String msg) {
|
|
var json = {};
|
|
try {
|
|
// back compatible
|
|
json = jsonDecode(msg);
|
|
} catch (err) {
|
|
final input = msg.split('').reversed.join('');
|
|
final bytes = base64Decode(base64.normalize(input));
|
|
json = jsonDecode(utf8.decode(bytes));
|
|
}
|
|
idServer = json['host'] ?? '';
|
|
relayServer = json['relay'] ?? '';
|
|
apiServer = json['api'] ?? '';
|
|
key = json['key'] ?? '';
|
|
}
|
|
|
|
/// encode to shared string
|
|
/// also see [ServerConfig.decode]
|
|
String encode() {
|
|
Map<String, String> config = {};
|
|
config['host'] = idServer.trim();
|
|
config['relay'] = relayServer.trim();
|
|
config['api'] = apiServer.trim();
|
|
config['key'] = key.trim();
|
|
return base64Encode(Uint8List.fromList(jsonEncode(config).codeUnits))
|
|
.split('')
|
|
.reversed
|
|
.join();
|
|
}
|
|
|
|
/// from local options
|
|
ServerConfig.fromOptions(Map<String, dynamic> options)
|
|
: idServer = options['custom-rendezvous-server'] ?? "",
|
|
relayServer = options['relay-server'] ?? "",
|
|
apiServer = options['api-server'] ?? "",
|
|
key = options['key'] ?? "";
|
|
}
|
|
|
|
Widget dialogButton(String text,
|
|
{required VoidCallback? onPressed,
|
|
bool isOutline = false,
|
|
Widget? icon,
|
|
TextStyle? style,
|
|
ButtonStyle? buttonStyle}) {
|
|
if (isDesktop || isWebDesktop) {
|
|
if (isOutline) {
|
|
return icon == null
|
|
? OutlinedButton(
|
|
onPressed: onPressed,
|
|
child: Text(translate(text), style: style),
|
|
)
|
|
: OutlinedButton.icon(
|
|
icon: icon,
|
|
onPressed: onPressed,
|
|
label: Text(translate(text), style: style),
|
|
);
|
|
} else {
|
|
return icon == null
|
|
? ElevatedButton(
|
|
style: ElevatedButton.styleFrom(elevation: 0).merge(buttonStyle),
|
|
onPressed: onPressed,
|
|
child: Text(translate(text), style: style),
|
|
)
|
|
: ElevatedButton.icon(
|
|
icon: icon,
|
|
style: ElevatedButton.styleFrom(elevation: 0).merge(buttonStyle),
|
|
onPressed: onPressed,
|
|
label: Text(translate(text), style: style),
|
|
);
|
|
}
|
|
} else {
|
|
return TextButton(
|
|
onPressed: onPressed,
|
|
child: Text(
|
|
translate(text),
|
|
style: style,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
int versionCmp(String v1, String v2) {
|
|
return bind.versionToNumber(v: v1) - bind.versionToNumber(v: v2);
|
|
}
|
|
|
|
String getWindowName({WindowType? overrideType}) {
|
|
final name = bind.mainGetAppNameSync();
|
|
switch (overrideType ?? kWindowType) {
|
|
case WindowType.Main:
|
|
return name;
|
|
case WindowType.FileTransfer:
|
|
return "File Transfer - $name";
|
|
case WindowType.PortForward:
|
|
return "Port Forward - $name";
|
|
case WindowType.RemoteDesktop:
|
|
return "Remote Desktop - $name";
|
|
default:
|
|
break;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
String getWindowNameWithId(String id, {WindowType? overrideType}) {
|
|
return "${DesktopTab.tablabelGetter(id).value} - ${getWindowName(overrideType: overrideType)}";
|
|
}
|
|
|
|
Future<void> updateSystemWindowTheme() async {
|
|
// Set system window theme for macOS.
|
|
final userPreference = MyTheme.getThemeModePreference();
|
|
if (userPreference != ThemeMode.system) {
|
|
if (isMacOS) {
|
|
await RdPlatformChannel.instance.changeSystemWindowTheme(
|
|
userPreference == ThemeMode.light
|
|
? SystemWindowTheme.light
|
|
: SystemWindowTheme.dark);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// macOS only
|
|
///
|
|
/// Note: not found a general solution for rust based AVFoundation bingding.
|
|
/// [AVFoundation] crate has compile error.
|
|
const kMacOSPermChannel = MethodChannel("org.rustdesk.rustdesk/macos");
|
|
|
|
enum PermissionAuthorizeType {
|
|
undetermined,
|
|
authorized,
|
|
denied, // and restricted
|
|
}
|
|
|
|
Future<PermissionAuthorizeType> osxCanRecordAudio() async {
|
|
int res = await kMacOSPermChannel.invokeMethod("canRecordAudio");
|
|
print(res);
|
|
if (res > 0) {
|
|
return PermissionAuthorizeType.authorized;
|
|
} else if (res == 0) {
|
|
return PermissionAuthorizeType.undetermined;
|
|
} else {
|
|
return PermissionAuthorizeType.denied;
|
|
}
|
|
}
|
|
|
|
Future<bool> osxRequestAudio() async {
|
|
return await kMacOSPermChannel.invokeMethod("requestRecordAudio");
|
|
}
|
|
|
|
class DraggableNeverScrollableScrollPhysics extends ScrollPhysics {
|
|
/// Creates scroll physics that does not let the user scroll.
|
|
const DraggableNeverScrollableScrollPhysics({super.parent});
|
|
|
|
@override
|
|
DraggableNeverScrollableScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
|
return DraggableNeverScrollableScrollPhysics(parent: buildParent(ancestor));
|
|
}
|
|
|
|
@override
|
|
bool shouldAcceptUserOffset(ScrollMetrics position) {
|
|
// TODO: find a better solution to check if the offset change is caused by the scrollbar.
|
|
// Workaround: when dragging with the scrollbar, it always triggers an [IdleScrollActivity].
|
|
if (position is ScrollPositionWithSingleContext) {
|
|
// ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member
|
|
return position.activity is IdleScrollActivity;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
bool get allowImplicitScrolling => false;
|
|
}
|
|
|
|
Widget futureBuilder(
|
|
{required Future? future, required Widget Function(dynamic data) hasData}) {
|
|
return FutureBuilder(
|
|
future: future,
|
|
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
|
if (snapshot.hasData) {
|
|
return hasData(snapshot.data!);
|
|
} else {
|
|
if (snapshot.hasError) {
|
|
debugPrint(snapshot.error.toString());
|
|
}
|
|
return Container();
|
|
}
|
|
});
|
|
}
|
|
|
|
void onCopyFingerprint(String value) {
|
|
if (value.isNotEmpty) {
|
|
Clipboard.setData(ClipboardData(text: value));
|
|
showToast('$value\n${translate("Copied")}');
|
|
} else {
|
|
showToast(translate("no fingerprints"));
|
|
}
|
|
}
|
|
|
|
Future<bool> callMainCheckSuperUserPermission() async {
|
|
bool checked = await bind.mainCheckSuperUserPermission();
|
|
if (isMacOS) {
|
|
await windowManager.show();
|
|
}
|
|
return checked;
|
|
}
|
|
|
|
Future<void> start_service(bool is_start) async {
|
|
bool checked = !bind.mainIsInstalled() ||
|
|
!isMacOS ||
|
|
await callMainCheckSuperUserPermission();
|
|
if (checked) {
|
|
mainSetBoolOption(kOptionStopService, !is_start);
|
|
}
|
|
}
|
|
|
|
Future<bool> canBeBlocked() async {
|
|
var access_mode = await bind.mainGetOption(key: kOptionAccessMode);
|
|
var option = option2bool(kOptionAllowRemoteConfigModification,
|
|
await bind.mainGetOption(key: kOptionAllowRemoteConfigModification));
|
|
return access_mode == 'view' || (access_mode.isEmpty && !option);
|
|
}
|
|
|
|
Future<void> shouldBeBlocked(RxBool block, WhetherUseRemoteBlock? use) async {
|
|
if (use != null && !await use()) {
|
|
block.value = false;
|
|
return;
|
|
}
|
|
var time0 = DateTime.now().millisecondsSinceEpoch;
|
|
await bind.mainCheckMouseTime();
|
|
Timer(const Duration(milliseconds: 120), () async {
|
|
var d = time0 - await bind.mainGetMouseTime();
|
|
if (d < 120) {
|
|
block.value = true;
|
|
} else {
|
|
block.value = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
typedef WhetherUseRemoteBlock = Future<bool> Function();
|
|
Widget buildRemoteBlock(
|
|
{required Widget child,
|
|
required RxBool block,
|
|
required bool mask,
|
|
WhetherUseRemoteBlock? use}) {
|
|
return Obx(() => MouseRegion(
|
|
onEnter: (_) async {
|
|
await shouldBeBlocked(block, use);
|
|
},
|
|
onExit: (event) => block.value = false,
|
|
child: Stack(children: [
|
|
// scope block tab
|
|
FocusScope(child: child, canRequestFocus: !block.value),
|
|
// mask block click, cm not block click and still use check_click_time to avoid block local click
|
|
if (mask)
|
|
Offstage(
|
|
offstage: !block.value,
|
|
child: Container(
|
|
color: Colors.black.withOpacity(0.5),
|
|
)),
|
|
]),
|
|
));
|
|
}
|
|
|
|
Widget unreadMessageCountBuilder(RxInt? count,
|
|
{double? size, double? fontSize}) {
|
|
return Obx(() => Offstage(
|
|
offstage: !((count?.value ?? 0) > 0),
|
|
child: Container(
|
|
width: size ?? 16,
|
|
height: size ?? 16,
|
|
decoration: BoxDecoration(
|
|
color: Colors.red,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Center(
|
|
child: Text("${count?.value ?? 0}",
|
|
maxLines: 1,
|
|
style: TextStyle(color: Colors.white, fontSize: fontSize ?? 10)),
|
|
),
|
|
)));
|
|
}
|
|
|
|
Widget unreadTopRightBuilder(RxInt? count, {Widget? icon}) {
|
|
return Stack(
|
|
children: [
|
|
icon ?? Icon(Icons.chat),
|
|
Positioned(
|
|
top: 0,
|
|
right: 0,
|
|
child: unreadMessageCountBuilder(count, size: 12, fontSize: 8))
|
|
],
|
|
);
|
|
}
|
|
|
|
String toCapitalized(String s) {
|
|
if (s.isEmpty) {
|
|
return s;
|
|
}
|
|
return s.substring(0, 1).toUpperCase() + s.substring(1);
|
|
}
|
|
|
|
Widget buildErrorBanner(BuildContext context,
|
|
{required RxBool loading,
|
|
required RxString err,
|
|
required Function? retry,
|
|
required Function close}) {
|
|
return Obx(() => Offstage(
|
|
offstage: !(!loading.value && err.value.isNotEmpty),
|
|
child: Center(
|
|
child: Container(
|
|
color: MyTheme.color(context).errorBannerBg,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
FittedBox(
|
|
child: Icon(
|
|
Icons.info,
|
|
color: Color.fromARGB(255, 249, 81, 81),
|
|
),
|
|
).marginAll(4),
|
|
Flexible(
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Tooltip(
|
|
message: translate(err.value),
|
|
child: SelectableText(
|
|
translate(err.value),
|
|
),
|
|
)).marginSymmetric(vertical: 2),
|
|
),
|
|
if (retry != null)
|
|
InkWell(
|
|
onTap: () {
|
|
retry.call();
|
|
},
|
|
child: Text(
|
|
translate("Retry"),
|
|
style: TextStyle(color: MyTheme.accent),
|
|
)).marginSymmetric(horizontal: 5),
|
|
FittedBox(
|
|
child: InkWell(
|
|
onTap: () {
|
|
close.call();
|
|
},
|
|
child: Icon(Icons.close).marginSymmetric(horizontal: 5),
|
|
),
|
|
).marginAll(4)
|
|
],
|
|
),
|
|
)).marginOnly(bottom: 14),
|
|
));
|
|
}
|
|
|
|
String getDesktopTabLabel(String peerId, String alias) {
|
|
String label = alias.isEmpty ? peerId : alias;
|
|
try {
|
|
String peer = bind.mainGetPeerSync(id: peerId);
|
|
Map<String, dynamic> config = jsonDecode(peer);
|
|
if (config['info']['hostname'] is String) {
|
|
String hostname = config['info']['hostname'];
|
|
if (hostname.isNotEmpty &&
|
|
!label.toLowerCase().contains(hostname.toLowerCase())) {
|
|
label += "@$hostname";
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Failed to get hostname:$e");
|
|
}
|
|
return label;
|
|
}
|
|
|
|
sessionRefreshVideo(SessionID sessionId, PeerInfo pi) async {
|
|
if (pi.currentDisplay == kAllDisplayValue) {
|
|
for (int i = 0; i < pi.displays.length; i++) {
|
|
await bind.sessionRefresh(sessionId: sessionId, display: i);
|
|
}
|
|
} else {
|
|
await bind.sessionRefresh(sessionId: sessionId, display: pi.currentDisplay);
|
|
}
|
|
}
|
|
|
|
Future<List<Rect>> getScreenListWayland() async {
|
|
final screenRectList = <Rect>[];
|
|
if (isMainDesktopWindow) {
|
|
for (var screen in await window_size.getScreenList()) {
|
|
final scale = kIgnoreDpi ? 1.0 : screen.scaleFactor;
|
|
double l = screen.frame.left;
|
|
double t = screen.frame.top;
|
|
double r = screen.frame.right;
|
|
double b = screen.frame.bottom;
|
|
final rect = Rect.fromLTRB(l / scale, t / scale, r / scale, b / scale);
|
|
screenRectList.add(rect);
|
|
}
|
|
} else {
|
|
final screenList = await rustDeskWinManager.call(
|
|
WindowType.Main, kWindowGetScreenList, '');
|
|
try {
|
|
for (var screen in jsonDecode(screenList.result) as List<dynamic>) {
|
|
final scale = kIgnoreDpi ? 1.0 : screen['scaleFactor'];
|
|
double l = screen['frame']['l'];
|
|
double t = screen['frame']['t'];
|
|
double r = screen['frame']['r'];
|
|
double b = screen['frame']['b'];
|
|
final rect = Rect.fromLTRB(l / scale, t / scale, r / scale, b / scale);
|
|
screenRectList.add(rect);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to parse screenList: $e');
|
|
}
|
|
}
|
|
return screenRectList;
|
|
}
|
|
|
|
Future<List<Rect>> getScreenListNotWayland() async {
|
|
final screenRectList = <Rect>[];
|
|
final displays = bind.mainGetDisplays();
|
|
if (displays.isEmpty) {
|
|
return screenRectList;
|
|
}
|
|
try {
|
|
for (var display in jsonDecode(displays) as List<dynamic>) {
|
|
// to-do: scale factor ?
|
|
// final scale = kIgnoreDpi ? 1.0 : screen.scaleFactor;
|
|
double l = display['x'].toDouble();
|
|
double t = display['y'].toDouble();
|
|
double r = (display['x'] + display['w']).toDouble();
|
|
double b = (display['y'] + display['h']).toDouble();
|
|
screenRectList.add(Rect.fromLTRB(l, t, r, b));
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to parse displays: $e');
|
|
}
|
|
return screenRectList;
|
|
}
|
|
|
|
Future<List<Rect>> getScreenRectList() async {
|
|
return bind.mainCurrentIsWayland()
|
|
? await getScreenListWayland()
|
|
: await getScreenListNotWayland();
|
|
}
|
|
|
|
openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi,
|
|
{bool updateCursorPos = true}) {
|
|
final displays = i == kAllDisplayValue
|
|
? List.generate(pi.displays.length, (index) => index)
|
|
: [i];
|
|
// Try clear image model before switching from all displays
|
|
// 1. The remote side has multiple displays.
|
|
// 2. Do not use texture render.
|
|
// 3. Connect to Display 1.
|
|
// 4. Switch to multi-displays `kAllDisplayValue`
|
|
// 5. Switch to Display 2.
|
|
// Then the remote page will display last picture of Display 1 at the beginning.
|
|
if (pi.forceTextureRender && i != kAllDisplayValue) {
|
|
ffi.imageModel.clearImage();
|
|
}
|
|
bind.sessionSwitchDisplay(
|
|
isDesktop: isDesktop,
|
|
sessionId: ffi.sessionId,
|
|
value: Int32List.fromList(displays),
|
|
);
|
|
ffi.ffiModel.switchToNewDisplay(i, ffi.sessionId, ffi.id,
|
|
updateCursorPos: updateCursorPos);
|
|
}
|
|
|
|
// Open new tab or window to show this monitor.
|
|
// For now just open new window.
|
|
//
|
|
// screenRect is used to move the new window to the specified screen and set fullscreen.
|
|
openMonitorInNewTabOrWindow(int i, String peerId, PeerInfo pi,
|
|
{Rect? screenRect}) {
|
|
final args = {
|
|
'window_id': stateGlobal.windowId,
|
|
'peer_id': peerId,
|
|
'display': i,
|
|
'display_count': pi.displays.length,
|
|
};
|
|
if (screenRect != null) {
|
|
args['screen_rect'] = {
|
|
'l': screenRect.left,
|
|
't': screenRect.top,
|
|
'r': screenRect.right,
|
|
'b': screenRect.bottom,
|
|
};
|
|
}
|
|
DesktopMultiWindow.invokeMethod(
|
|
kMainWindowId, kWindowEventOpenMonitorSession, jsonEncode(args));
|
|
}
|
|
|
|
setNewConnectWindowFrame(int windowId, String peerId, int preSessionCount,
|
|
int? display, Rect? screenRect) async {
|
|
if (screenRect == null) {
|
|
// Do not restore window position to new connection if there's a pre-session.
|
|
// https://github.com/rustdesk/rustdesk/discussions/8825
|
|
if (preSessionCount == 0) {
|
|
await restoreWindowPosition(WindowType.RemoteDesktop,
|
|
windowId: windowId, display: display, peerId: peerId);
|
|
}
|
|
} else {
|
|
await tryMoveToScreenAndSetFullscreen(screenRect);
|
|
}
|
|
}
|
|
|
|
tryMoveToScreenAndSetFullscreen(Rect? screenRect) async {
|
|
if (screenRect == null) {
|
|
return;
|
|
}
|
|
final wc = WindowController.fromWindowId(stateGlobal.windowId);
|
|
final curFrame = await wc.getFrame();
|
|
final frame =
|
|
Rect.fromLTWH(screenRect.left + 30, screenRect.top + 30, 600, 400);
|
|
if (stateGlobal.fullscreen.isTrue &&
|
|
curFrame.left <= frame.left &&
|
|
curFrame.top <= frame.top &&
|
|
curFrame.width >= frame.width &&
|
|
curFrame.height >= frame.height) {
|
|
return;
|
|
}
|
|
await wc.setFrame(frame);
|
|
// An duration is needed to avoid the window being restored after fullscreen.
|
|
Future.delayed(Duration(milliseconds: 300), () async {
|
|
stateGlobal.setFullscreen(true);
|
|
});
|
|
}
|
|
|
|
parseParamScreenRect(Map<String, dynamic> params) {
|
|
Rect? screenRect;
|
|
if (params['screen_rect'] != null) {
|
|
double l = params['screen_rect']['l'];
|
|
double t = params['screen_rect']['t'];
|
|
double r = params['screen_rect']['r'];
|
|
double b = params['screen_rect']['b'];
|
|
screenRect = Rect.fromLTRB(l, t, r, b);
|
|
}
|
|
return screenRect;
|
|
}
|
|
|
|
get isInputSourceFlutter => stateGlobal.getInputSource() == "Input source 2";
|
|
|
|
class _ReconnectCountDownButton extends StatefulWidget {
|
|
_ReconnectCountDownButton({
|
|
Key? key,
|
|
required this.second,
|
|
required this.onPressed,
|
|
}) : super(key: key);
|
|
final VoidCallback? onPressed;
|
|
final int second;
|
|
|
|
@override
|
|
State<_ReconnectCountDownButton> createState() =>
|
|
_ReconnectCountDownButtonState();
|
|
}
|
|
|
|
class _ReconnectCountDownButtonState extends State<_ReconnectCountDownButton> {
|
|
late int _countdownSeconds = widget.second;
|
|
|
|
Timer? _timer;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_startCountdownTimer();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _startCountdownTimer() {
|
|
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
|
|
if (_countdownSeconds <= 0) {
|
|
timer.cancel();
|
|
} else {
|
|
setState(() {
|
|
_countdownSeconds--;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return dialogButton(
|
|
'${translate('Reconnect')} (${_countdownSeconds}s)',
|
|
onPressed: widget.onPressed,
|
|
isOutline: true,
|
|
);
|
|
}
|
|
}
|
|
|
|
importConfig(List<TextEditingController>? controllers, List<RxString>? errMsgs,
|
|
String? text) {
|
|
if (text != null && text.isNotEmpty) {
|
|
try {
|
|
final sc = ServerConfig.decode(text);
|
|
if (sc.idServer.isNotEmpty) {
|
|
Future<bool> success = setServerConfig(controllers, errMsgs, sc);
|
|
success.then((value) {
|
|
if (value) {
|
|
showToast(translate('Import server configuration successfully'));
|
|
} else {
|
|
showToast(translate('Invalid server configuration'));
|
|
}
|
|
});
|
|
} else {
|
|
showToast(translate('Invalid server configuration'));
|
|
}
|
|
return sc;
|
|
} catch (e) {
|
|
showToast(translate('Invalid server configuration'));
|
|
}
|
|
} else {
|
|
showToast(translate('Clipboard is empty'));
|
|
}
|
|
}
|
|
|
|
Future<bool> setServerConfig(
|
|
List<TextEditingController>? controllers,
|
|
List<RxString>? errMsgs,
|
|
ServerConfig config,
|
|
) async {
|
|
String removeEndSlash(String input) {
|
|
if (input.endsWith('/')) {
|
|
return input.substring(0, input.length - 1);
|
|
}
|
|
return input;
|
|
}
|
|
|
|
config.idServer = removeEndSlash(config.idServer.trim());
|
|
config.relayServer = removeEndSlash(config.relayServer.trim());
|
|
config.apiServer = removeEndSlash(config.apiServer.trim());
|
|
config.key = config.key.trim();
|
|
if (controllers != null) {
|
|
controllers[0].text = config.idServer;
|
|
controllers[1].text = config.relayServer;
|
|
controllers[2].text = config.apiServer;
|
|
controllers[3].text = config.key;
|
|
}
|
|
// id
|
|
if (config.idServer.isNotEmpty && errMsgs != null) {
|
|
errMsgs[0].value = translate(await bind.mainTestIfValidServer(
|
|
server: config.idServer, testWithProxy: true));
|
|
if (errMsgs[0].isNotEmpty) {
|
|
return false;
|
|
}
|
|
}
|
|
// relay
|
|
if (config.relayServer.isNotEmpty && errMsgs != null) {
|
|
errMsgs[1].value = translate(await bind.mainTestIfValidServer(
|
|
server: config.relayServer, testWithProxy: true));
|
|
if (errMsgs[1].isNotEmpty) {
|
|
return false;
|
|
}
|
|
}
|
|
// api
|
|
if (config.apiServer.isNotEmpty && errMsgs != null) {
|
|
if (!config.apiServer.startsWith('http://') &&
|
|
!config.apiServer.startsWith('https://')) {
|
|
errMsgs[2].value =
|
|
'${translate("API Server")}: ${translate("invalid_http")}';
|
|
return false;
|
|
}
|
|
}
|
|
final oldApiServer = await bind.mainGetApiServer();
|
|
|
|
// should set one by one
|
|
await bind.mainSetOption(
|
|
key: 'custom-rendezvous-server', value: config.idServer);
|
|
await bind.mainSetOption(key: 'relay-server', value: config.relayServer);
|
|
await bind.mainSetOption(key: 'api-server', value: config.apiServer);
|
|
await bind.mainSetOption(key: 'key', value: config.key);
|
|
final newApiServer = await bind.mainGetApiServer();
|
|
if (oldApiServer.isNotEmpty &&
|
|
oldApiServer != newApiServer &&
|
|
gFFI.userModel.isLogin) {
|
|
gFFI.userModel.logOut(apiServer: oldApiServer);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
ColorFilter? svgColor(Color? color) {
|
|
if (color == null) {
|
|
return null;
|
|
} else {
|
|
return ColorFilter.mode(color, BlendMode.srcIn);
|
|
}
|
|
}
|
|
|
|
// ignore: must_be_immutable
|
|
class ComboBox extends StatelessWidget {
|
|
late final List<String> keys;
|
|
late final List<String> values;
|
|
late final String initialKey;
|
|
late final Function(String key) onChanged;
|
|
late final bool enabled;
|
|
late String current;
|
|
|
|
ComboBox({
|
|
Key? key,
|
|
required this.keys,
|
|
required this.values,
|
|
required this.initialKey,
|
|
required this.onChanged,
|
|
this.enabled = true,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var index = keys.indexOf(initialKey);
|
|
if (index < 0) {
|
|
index = 0;
|
|
}
|
|
var ref = values[index].obs;
|
|
current = keys[index];
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: enabled
|
|
? MyTheme.color(context).border2 ?? MyTheme.border
|
|
: MyTheme.border,
|
|
),
|
|
borderRadius:
|
|
BorderRadius.circular(8), //border raiuds of dropdown button
|
|
),
|
|
height: 42, // should be the height of a TextField
|
|
child: Obx(() => DropdownButton<String>(
|
|
isExpanded: true,
|
|
value: ref.value,
|
|
elevation: 16,
|
|
underline: Container(),
|
|
style: TextStyle(
|
|
color: enabled
|
|
? Theme.of(context).textTheme.titleMedium?.color
|
|
: disabledTextColor(context, enabled)),
|
|
icon: const Icon(
|
|
Icons.expand_more_sharp,
|
|
size: 20,
|
|
).marginOnly(right: 15),
|
|
onChanged: enabled
|
|
? (String? newValue) {
|
|
if (newValue != null && newValue != ref.value) {
|
|
ref.value = newValue;
|
|
current = newValue;
|
|
onChanged(keys[values.indexOf(newValue)]);
|
|
}
|
|
}
|
|
: null,
|
|
items: values.map<DropdownMenuItem<String>>((String value) {
|
|
return DropdownMenuItem<String>(
|
|
value: value,
|
|
child: Text(
|
|
value,
|
|
style: const TextStyle(fontSize: 15),
|
|
overflow: TextOverflow.ellipsis,
|
|
).marginOnly(left: 15),
|
|
);
|
|
}).toList(),
|
|
)),
|
|
).marginOnly(bottom: 5);
|
|
}
|
|
}
|
|
|
|
Color? disabledTextColor(BuildContext context, bool enabled) {
|
|
return enabled
|
|
? null
|
|
: Theme.of(context).textTheme.titleLarge?.color?.withOpacity(0.6);
|
|
}
|
|
|
|
Widget loadPowered(BuildContext context) {
|
|
return MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
launchUrl(Uri.parse('https://rustdesk.com'));
|
|
},
|
|
child: Opacity(
|
|
opacity: 0.5,
|
|
child: Text(
|
|
translate("powered_by_me"),
|
|
overflow: TextOverflow.clip,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(fontSize: 9, decoration: TextDecoration.underline),
|
|
)),
|
|
),
|
|
).marginOnly(top: 6);
|
|
}
|
|
|
|
// max 300 x 60
|
|
Widget loadLogo() {
|
|
return FutureBuilder<ByteData>(
|
|
future: rootBundle.load('assets/logo.png'),
|
|
builder: (BuildContext context, AsyncSnapshot<ByteData> snapshot) {
|
|
if (snapshot.hasData) {
|
|
final image = Image.asset(
|
|
'assets/logo.png',
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (ctx, error, stackTrace) {
|
|
return Container();
|
|
},
|
|
);
|
|
return Container(
|
|
constraints: BoxConstraints(maxWidth: 300, maxHeight: 60),
|
|
child: image,
|
|
).marginOnly(left: 12, right: 12, top: 12);
|
|
}
|
|
return const Offstage();
|
|
});
|
|
}
|
|
|
|
Widget loadIcon(double size) {
|
|
return Image.asset('assets/icon.png',
|
|
width: size,
|
|
height: size,
|
|
errorBuilder: (ctx, error, stackTrace) => SvgPicture.asset(
|
|
'assets/icon.svg',
|
|
width: size,
|
|
height: size,
|
|
));
|
|
}
|
|
|
|
var imcomingOnlyHomeSize = Size(280, 300);
|
|
Size getIncomingOnlyHomeSize() {
|
|
final magicWidth = isWindows ? 11.0 : 2.0;
|
|
final magicHeight = 10.0;
|
|
return imcomingOnlyHomeSize +
|
|
Offset(magicWidth, kDesktopRemoteTabBarHeight + magicHeight);
|
|
}
|
|
|
|
Size getIncomingOnlySettingsSize() {
|
|
return Size(768, 600);
|
|
}
|
|
|
|
bool isInHomePage() {
|
|
final controller = Get.find<DesktopTabController>();
|
|
return controller.state.value.selected == 0;
|
|
}
|
|
|
|
Widget _buildPresetPasswordWarning() {
|
|
if (bind.mainGetBuildinOption(key: kOptionRemovePresetPasswordWarning) !=
|
|
'N') {
|
|
return SizedBox.shrink();
|
|
}
|
|
return Container(
|
|
color: Colors.yellow,
|
|
child: Column(
|
|
children: [
|
|
Align(
|
|
child: Text(
|
|
translate("Security Alert"),
|
|
style: TextStyle(
|
|
color: Colors.red,
|
|
fontSize:
|
|
18, // https://github.com/rustdesk/rustdesk-server-pro/issues/261
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
)).paddingOnly(bottom: 8),
|
|
Text(
|
|
translate("preset_password_warning"),
|
|
style: TextStyle(color: Colors.red),
|
|
)
|
|
],
|
|
).paddingAll(8),
|
|
); // Show a warning message if the Future completed with true
|
|
}
|
|
|
|
Widget buildPresetPasswordWarningMobile() {
|
|
if (bind.isPresetPasswordMobileOnly()) {
|
|
return _buildPresetPasswordWarning();
|
|
} else {
|
|
return SizedBox.shrink();
|
|
}
|
|
}
|
|
|
|
Widget buildPresetPasswordWarning() {
|
|
return FutureBuilder<bool>(
|
|
future: bind.isPresetPassword(),
|
|
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return CircularProgressIndicator(); // Show a loading spinner while waiting for the Future to complete
|
|
} else if (snapshot.hasError) {
|
|
return Text(
|
|
'Error: ${snapshot.error}'); // Show an error message if the Future completed with an error
|
|
} else if (snapshot.hasData && snapshot.data == true) {
|
|
return _buildPresetPasswordWarning();
|
|
} else {
|
|
return SizedBox
|
|
.shrink(); // Show nothing if the Future completed with false or null
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
// https://github.com/leanflutter/window_manager/blob/87dd7a50b4cb47a375b9fc697f05e56eea0a2ab3/lib/src/widgets/virtual_window_frame.dart#L44
|
|
Widget buildVirtualWindowFrame(BuildContext context, Widget child) {
|
|
boxShadow() => isMainDesktopWindow
|
|
? <BoxShadow>[
|
|
if (stateGlobal.fullscreen.isFalse || stateGlobal.isMaximized.isFalse)
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
offset: Offset(
|
|
0.0,
|
|
stateGlobal.isFocused.isTrue
|
|
? kFrameBoxShadowOffsetFocused
|
|
: kFrameBoxShadowOffsetUnfocused),
|
|
blurRadius: kFrameBoxShadowBlurRadius,
|
|
),
|
|
]
|
|
: null;
|
|
return Obx(
|
|
() => Container(
|
|
decoration: BoxDecoration(
|
|
color: isMainDesktopWindow
|
|
? Colors.transparent
|
|
: Theme.of(context).colorScheme.background,
|
|
border: Border.all(
|
|
color: Theme.of(context).dividerColor,
|
|
width: stateGlobal.windowBorderWidth.value,
|
|
),
|
|
borderRadius: BorderRadius.circular(
|
|
(stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.isTrue)
|
|
? 0
|
|
: kFrameBorderRadius,
|
|
),
|
|
boxShadow: boxShadow(),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
(stateGlobal.fullscreen.isTrue || stateGlobal.isMaximized.isTrue)
|
|
? 0
|
|
: kFrameClipRRectBorderRadius,
|
|
),
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
get windowEdgeSize => isLinux && !_linuxWindowResizable ? 0.0 : kWindowEdgeSize;
|
|
|
|
// `windowManager.setResizable(false)` will reset the window size to the default size on Linux and then set unresizable.
|
|
// See _linuxWindowResizable for more details.
|
|
// So we use `setResizable()` instead of `windowManager.setResizable()`.
|
|
//
|
|
// We can only call `windowManager.setResizable(false)` if we need the default size on Linux.
|
|
setResizable(bool resizable) {
|
|
if (isLinux) {
|
|
_linuxWindowResizable = resizable;
|
|
stateGlobal.refreshResizeEdgeSize();
|
|
} else {
|
|
windowManager.setResizable(resizable);
|
|
}
|
|
}
|
|
|
|
isOptionFixed(String key) => bind.mainIsOptionFixed(key: key);
|
|
|
|
bool? _isCustomClient;
|
|
bool get isCustomClient {
|
|
_isCustomClient ??= bind.isCustomClient();
|
|
return _isCustomClient!;
|
|
}
|
|
|
|
get defaultOptionLang => isCustomClient ? 'default' : '';
|
|
get defaultOptionTheme => isCustomClient ? 'system' : '';
|
|
get defaultOptionYes => isCustomClient ? 'Y' : '';
|
|
get defaultOptionNo => isCustomClient ? 'N' : '';
|
|
get defaultOptionWhitelist => isCustomClient ? ',' : '';
|
|
get defaultOptionAccessMode => isCustomClient ? 'custom' : '';
|
|
get defaultOptionApproveMode => isCustomClient ? 'password-click' : '';
|
|
|
|
bool whitelistNotEmpty() {
|
|
// https://rustdesk.com/docs/en/self-host/client-configuration/advanced-settings/#whitelist
|
|
final v = bind.mainGetOptionSync(key: kOptionWhitelist);
|
|
return v != '' && v != ',';
|
|
}
|
|
|
|
// `setMovable()` is only supported on macOS.
|
|
//
|
|
// On macOS, the window can be dragged by the tab bar by default.
|
|
// We need to disable the movable feature to prevent the window from being dragged by the tabs in the tab bar.
|
|
//
|
|
// When we drag the blank tab bar (not the tab), the window will be dragged normally by adding the `onPanStart` handle.
|
|
//
|
|
// See the following code for more details:
|
|
// https://github.com/rustdesk/rustdesk/blob/ce1dac3b8613596b4d8ae981275f9335489eb935/flutter/lib/desktop/widgets/tabbar_widget.dart#L385
|
|
// https://github.com/rustdesk/rustdesk/blob/ce1dac3b8613596b4d8ae981275f9335489eb935/flutter/lib/desktop/widgets/tabbar_widget.dart#L399
|
|
//
|
|
// @platforms macos
|
|
disableWindowMovable(int? windowId) {
|
|
if (!isMacOS) {
|
|
return;
|
|
}
|
|
|
|
if (windowId == null) {
|
|
windowManager.setMovable(false);
|
|
} else {
|
|
WindowController.fromWindowId(windowId).setMovable(false);
|
|
}
|
|
}
|
|
|
|
Widget netWorkErrorWidget() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Text(translate("network_error_tip")),
|
|
ElevatedButton(
|
|
onPressed: gFFI.userModel.refreshCurrentUser,
|
|
child: Text(translate("Retry")))
|
|
.marginSymmetric(vertical: 16),
|
|
SelectableText(gFFI.userModel.networkError.value,
|
|
style: TextStyle(fontSize: 11, color: Colors.red)),
|
|
],
|
|
));
|
|
}
|