Initial commit: RustDesk 1.4.7 macOS desktop port

- Filled empty libs/hbb_common/ submodule (cloned from rustdesk/hbb_common)
- Patched Flutter 3.44 / Dart 3.12 compatibility:
  * flutter/lib/generated_bridge.dart: asTypedList with cast<>, DartPort=Int64
  * flutter/lib/common.dart: DialogTheme->DialogThemeData, TabBarTheme->TabBarThemeData
  * flutter/pubspec.yaml: extended_text 14.0.0->15.0.2, google_fonts override 5.0.0
  * flutter/macos/Runner/Configs/Release.xcconfig: EXCLUDED_ARCHS=x86_64
- Build verified: cargo check + cargo build --features flutter + cargo build --release --features flutter
- Verified flutter build macos --debug and --release both produce working .app
- Verified .dmg installer (27MB arm64) created via hdiutil
- Build deps: Xcode 26.5, CocoaPods 1.16.2, Flutter 3.44, VCPKG arm64-osx
This commit is contained in:
xuwenwei
2026-06-08 21:42:20 +08:00
parent 3d8db09123
commit d3b6026bfd
840 changed files with 291780 additions and 1 deletions
+171
View File
@@ -0,0 +1,171 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
class Button extends StatefulWidget {
final GestureTapCallback onTap;
final String text;
final double? textSize;
final double? minWidth;
final bool isOutline;
final double? padding;
final Color? textColor;
final double? radius;
final Color? borderColor;
Button({
Key? key,
this.minWidth,
this.isOutline = false,
this.textSize,
this.padding,
this.textColor,
this.radius,
this.borderColor,
required this.onTap,
required this.text,
}) : super(key: key);
@override
State<Button> createState() => _ButtonState();
}
class _ButtonState extends State<Button> {
RxBool hover = false.obs;
RxBool pressed = false.obs;
@override
Widget build(BuildContext context) {
return Obx(() => InkWell(
onTapDown: (_) => pressed.value = true,
onTapUp: (_) => pressed.value = false,
onTapCancel: () => pressed.value = false,
onHover: (value) => hover.value = value,
onTap: widget.onTap,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: widget.minWidth ?? 70.0,
),
child: Container(
padding: EdgeInsets.all(widget.padding ?? 4.5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: pressed.value
? MyTheme.accent
: (widget.isOutline
? Colors.transparent
: MyTheme.button),
border: Border.all(
color: pressed.value
? MyTheme.accent
: hover.value
? MyTheme.hoverBorder
: (widget.isOutline
? widget.borderColor ?? MyTheme.border
: MyTheme.button),
),
borderRadius: BorderRadius.circular(widget.radius ?? 5),
),
child: Text(
translate(
widget.text,
),
style: TextStyle(
fontSize: widget.textSize ?? 12.0,
color: widget.isOutline
? widget.textColor ??
Theme.of(context).textTheme.titleLarge?.color
: Colors.white),
).marginSymmetric(horizontal: 12),
)),
));
}
}
class FixedWidthButton extends StatefulWidget {
final GestureTapCallback onTap;
final String text;
final double? textSize;
final double width;
final bool isOutline;
final double? padding;
final Color? textColor;
final double? radius;
final Color? borderColor;
final int? maxLines;
FixedWidthButton({
Key? key,
required this.width,
this.maxLines,
this.isOutline = false,
this.textSize,
this.padding,
this.textColor,
this.radius,
this.borderColor,
required this.onTap,
required this.text,
}) : super(key: key);
@override
State<FixedWidthButton> createState() => _FixedWidthButtonState();
}
class _FixedWidthButtonState extends State<FixedWidthButton> {
RxBool hover = false.obs;
RxBool pressed = false.obs;
@override
Widget build(BuildContext context) {
return Obx(() => InkWell(
onTapDown: (_) => pressed.value = true,
onTapUp: (_) => pressed.value = false,
onTapCancel: () => pressed.value = false,
onHover: (value) => hover.value = value,
onTap: widget.onTap,
child: Container(
width: widget.width,
padding: EdgeInsets.all(widget.padding ?? 4.5),
alignment: Alignment.center,
decoration: BoxDecoration(
color: pressed.value
? MyTheme.accent
: (widget.isOutline ? Colors.transparent : MyTheme.button),
border: Border.all(
color: pressed.value
? MyTheme.accent
: hover.value
? MyTheme.hoverBorder
: (widget.isOutline
? widget.borderColor ?? MyTheme.border
: MyTheme.button),
),
borderRadius: BorderRadius.circular(widget.radius ?? 5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: AutoSizeText(
translate(
widget.text,
),
maxLines: widget.maxLines ?? 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: widget.textSize ?? 12.0,
color: widget.isOutline
? widget.textColor ??
Theme.of(context).textTheme.titleLarge?.color
: Colors.white),
).marginSymmetric(horizontal: 12),
),
],
),
),
));
}
}
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class DraggableDivider extends StatefulWidget {
final Axis axis;
final double thickness;
final Color color;
final Function(double)? onPointerMove;
final VoidCallback? onHover;
final EdgeInsets padding;
const DraggableDivider({
super.key,
this.axis = Axis.horizontal,
this.thickness = 1.0,
this.color = const Color.fromARGB(200, 177, 175, 175),
this.onPointerMove,
this.padding = const EdgeInsets.symmetric(horizontal: 1.0),
this.onHover,
});
@override
State<DraggableDivider> createState() => _DraggableDividerState();
}
class _DraggableDividerState extends State<DraggableDivider> {
@override
Widget build(BuildContext context) {
return Listener(
onPointerMove: (event) {
final dl = widget.axis == Axis.horizontal
? event.localDelta.dy
: event.localDelta.dx;
widget.onPointerMove?.call(dl);
},
onPointerHover: (event) => widget.onHover?.call(),
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: Padding(
padding: widget.padding,
child: Container(
decoration: BoxDecoration(color: widget.color),
width: widget.axis == Axis.horizontal
? double.infinity
: widget.thickness,
height: widget.axis == Axis.horizontal
? widget.thickness
: double.infinity,
),
),
),
);
}
}
@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import '../../common.dart';
typedef KBChosenCallback = Future<bool> Function(String);
const double _kImageMarginVertical = 6.0;
const double _kImageMarginHorizontal = 10.0;
const double _kImageBoarderWidth = 4.0;
const double _kImagePaddingWidth = 4.0;
const Color _kImageBorderColor = Color.fromARGB(125, 202, 247, 2);
const double _kBorderRadius = 6.0;
const String _kKBLayoutTypeISO = 'ISO';
const String _kKBLayoutTypeNotISO = 'Not ISO';
const _kKBLayoutImageMap = {
_kKBLayoutTypeISO: 'kb_layout_iso',
_kKBLayoutTypeNotISO: 'kb_layout_not_iso',
};
class _KBImage extends StatelessWidget {
final String kbLayoutType;
final double imageWidth;
final RxString chosenType;
const _KBImage({
Key? key,
required this.kbLayoutType,
required this.imageWidth,
required this.chosenType,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(() {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kBorderRadius),
border: Border.all(
color: chosenType.value == kbLayoutType
? _kImageBorderColor
: Colors.transparent,
width: _kImageBoarderWidth,
),
),
margin: EdgeInsets.symmetric(
horizontal: _kImageMarginHorizontal,
vertical: _kImageMarginVertical,
),
padding: EdgeInsets.all(_kImagePaddingWidth),
child: SvgPicture.asset(
'assets/${_kKBLayoutImageMap[kbLayoutType] ?? ""}.svg',
width: imageWidth -
_kImageMarginHorizontal * 2 -
_kImagePaddingWidth * 2 -
_kImageBoarderWidth * 2,
),
);
});
}
}
class _KBChooser extends StatelessWidget {
final String kbLayoutType;
final double imageWidth;
final RxString chosenType;
final KBChosenCallback cb;
const _KBChooser({
Key? key,
required this.kbLayoutType,
required this.imageWidth,
required this.chosenType,
required this.cb,
}) : super(key: key);
@override
Widget build(BuildContext context) {
onChanged(String? v) async {
if (v != null) {
if (await cb(v)) {
chosenType.value = v;
}
}
}
return Column(
children: [
TextButton(
onPressed: () {
onChanged(kbLayoutType);
},
child: _KBImage(
kbLayoutType: kbLayoutType,
imageWidth: imageWidth,
chosenType: chosenType,
),
style: TextButton.styleFrom(padding: EdgeInsets.zero),
),
TextButton(
child: Row(
children: [
Obx(() => Radio(
splashRadius: 0,
value: kbLayoutType,
groupValue: chosenType.value,
onChanged: onChanged,
)),
Text(kbLayoutType),
],
),
onPressed: () {
onChanged(kbLayoutType);
},
),
],
);
}
}
class KBLayoutTypeChooser extends StatelessWidget {
final RxString chosenType;
final double width;
final double height;
final double dividerWidth;
final KBChosenCallback cb;
KBLayoutTypeChooser({
Key? key,
required this.chosenType,
required this.width,
required this.height,
required this.dividerWidth,
required this.cb,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final imageWidth = width / 2 - dividerWidth;
return SizedBox(
width: width,
height: height,
child: Center(
child: Row(
children: [
_KBChooser(
kbLayoutType: _kKBLayoutTypeISO,
imageWidth: imageWidth,
chosenType: chosenType,
cb: cb,
),
VerticalDivider(
width: dividerWidth * 2,
),
_KBChooser(
kbLayoutType: _kKBLayoutTypeNotISO,
imageWidth: imageWidth,
chosenType: chosenType,
cb: cb,
),
],
),
),
);
}
}
RxString KBLayoutType = ''.obs;
String getLocalPlatformForKBLayoutType(String peerPlatform) {
String localPlatform = '';
if (peerPlatform != kPeerPlatformMacOS) {
return localPlatform;
}
if (isWindows) {
localPlatform = kPeerPlatformWindows;
} else if (isLinux) {
localPlatform = kPeerPlatformLinux;
} else if (isWebOnWindows || isWebOnLinux) {
localPlatform = kPeerPlatformWebDesktop;
}
return localPlatform;
}
showKBLayoutTypeChooserIfNeeded(
String peerPlatform,
OverlayDialogManager dialogManager,
) async {
final localPlatform = getLocalPlatformForKBLayoutType(peerPlatform);
if (localPlatform == '') {
return;
}
KBLayoutType.value = bind.getLocalKbLayoutType();
if (KBLayoutType.value == _kKBLayoutTypeISO ||
KBLayoutType.value == _kKBLayoutTypeNotISO) {
return;
}
showKBLayoutTypeChooser(localPlatform, dialogManager);
}
showKBLayoutTypeChooser(
String localPlatform,
OverlayDialogManager dialogManager,
) {
dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title:
Text('${translate('Select local keyboard type')} ($localPlatform)'),
content: KBLayoutTypeChooser(
chosenType: KBLayoutType,
width: 360,
height: 200,
dividerWidth: 4.0,
cb: (String v) async {
await bind.setLocalKbLayoutType(kbLayoutType: v);
KBLayoutType.value = bind.getLocalKbLayoutType();
return v == KBLayoutType.value;
}),
actions: [dialogButton('Close', onPressed: close)],
onCancel: close,
);
});
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
class ListSearchActionListener extends StatelessWidget {
final FocusNode node;
final TimeoutStringBuffer buffer;
final Widget child;
final Function(String) onNext;
final Function(String) onSearch;
const ListSearchActionListener(
{super.key,
required this.node,
required this.buffer,
required this.child,
required this.onNext,
required this.onSearch});
@mustCallSuper
@override
Widget build(BuildContext context) {
return KeyboardListener(
autofocus: true,
onKeyEvent: (kv) {
final ch = kv.character;
if (ch == null) {
return;
}
final action = buffer.input(ch);
switch (action) {
case ListSearchAction.search:
onSearch(buffer.buffer);
break;
case ListSearchAction.next:
onNext(buffer.buffer);
break;
}
},
focusNode: node,
child: child);
}
}
enum ListSearchAction { search, next }
class TimeoutStringBuffer {
var _buffer = "";
late DateTime _duration;
static int timeoutMilliSec = 1500;
String get buffer => _buffer;
TimeoutStringBuffer() {
_duration = DateTime.now();
}
ListSearchAction input(String ch) {
ch = ch.toLowerCase();
final curr = DateTime.now();
try {
if (curr.difference(_duration).inMilliseconds > timeoutMilliSec) {
_buffer = ch;
return ListSearchAction.search;
} else {
if (ch == _buffer) {
return ListSearchAction.next;
} else {
_buffer += ch;
return ListSearchAction.search;
}
}
} finally {
_duration = curr;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
class MenuButton extends StatefulWidget {
final GestureTapCallback? onPressed;
final Color color;
final Color hoverColor;
final Color? splashColor;
final Widget child;
final String? tooltip;
final EdgeInsetsGeometry padding;
final bool enableFeedback;
const MenuButton({
super.key,
required this.onPressed,
required this.color,
required this.hoverColor,
required this.child,
this.splashColor,
this.tooltip = "",
this.padding = const EdgeInsets.symmetric(horizontal: 3, vertical: 6),
this.enableFeedback = true,
});
@override
State<MenuButton> createState() => _MenuButtonState();
}
class _MenuButtonState extends State<MenuButton> {
bool _isHover = false;
final double _borderRadius = 8.0;
@override
Widget build(BuildContext context) {
return Padding(
padding: widget.padding,
child: Tooltip(
waitDuration: Duration(milliseconds: 300),
message: widget.tooltip,
child: Material(
type: MaterialType.transparency,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_borderRadius),
color: _isHover ? widget.hoverColor : widget.color,
),
child: InkWell(
hoverColor: widget.hoverColor,
onHover: (val) {
setState(() {
_isHover = val;
});
},
borderRadius: BorderRadius.circular(_borderRadius),
splashColor: widget.splashColor,
enableFeedback: widget.enableFeedback,
onTap: widget.onPressed,
child: widget.child,
),
),
),
),
);
}
}
+755
View File
@@ -0,0 +1,755 @@
import 'dart:core';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import './material_mod_popup_menu.dart' as mod_menu;
// https://stackoverflow.com/questions/68318314/flutter-popup-menu-inside-popup-menu
class PopupMenuChildrenItem<T> extends mod_menu.PopupMenuEntry<T> {
const PopupMenuChildrenItem({
key,
this.height = kMinInteractiveDimension,
this.padding,
this.enabled,
this.textStyle,
this.onTap,
this.position = mod_menu.PopupMenuPosition.overSide,
this.offset = Offset.zero,
required this.itemBuilder,
required this.child,
}) : super(key: key);
final mod_menu.PopupMenuPosition position;
final Offset offset;
final TextStyle? textStyle;
final EdgeInsets? padding;
final RxBool? enabled;
final void Function()? onTap;
final List<mod_menu.PopupMenuEntry<T>> Function(BuildContext) itemBuilder;
final Widget child;
@override
final double height;
@override
bool represents(T? value) => false;
@override
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>> createState() =>
MyPopupMenuItemState<T, PopupMenuChildrenItem<T>>(enabled?.value);
}
class MyPopupMenuItemState<T, W extends PopupMenuChildrenItem<T>>
extends State<W> {
RxBool enabled = true.obs;
MyPopupMenuItemState(bool? e) {
if (e != null) {
enabled.value = e;
}
}
@protected
void handleTap(T value) {
widget.onTap?.call();
Navigator.pop<T>(context, value);
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final PopupMenuThemeData popupMenuTheme = PopupMenuTheme.of(context);
TextStyle style = widget.textStyle ??
popupMenuTheme.textStyle ??
theme.textTheme.titleMedium!;
return Obx(() => mod_menu.PopupMenuButton<T>(
enabled: enabled.value,
position: widget.position,
offset: widget.offset,
onSelected: handleTap,
itemBuilder: widget.itemBuilder,
padding: EdgeInsets.zero,
child: AnimatedDefaultTextStyle(
style: style,
duration: kThemeChangeDuration,
child: Container(
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints(
minHeight: widget.height, maxHeight: widget.height),
padding:
widget.padding ?? const EdgeInsets.symmetric(horizontal: 16),
child: widget.child,
),
),
));
}
}
class MenuConfig {
// adapt to the screen height
static const fontSize = 14.0;
static const midPadding = 10.0;
static const iconScale = 0.8;
static const iconWidth = 12.0;
static const iconHeight = 12.0;
final double height;
final double dividerHeight;
final double? boxWidth;
final Color commonColor;
const MenuConfig(
{required this.commonColor,
this.height = kMinInteractiveDimension,
this.dividerHeight = 16.0,
this.boxWidth});
}
typedef DismissCallback = Function();
abstract class MenuEntryBase<T> {
bool dismissOnClicked;
DismissCallback? dismissCallback;
RxBool? enabled;
MenuEntryBase({
this.dismissOnClicked = false,
this.enabled,
this.dismissCallback,
});
List<mod_menu.PopupMenuEntry<T>> build(BuildContext context, MenuConfig conf);
enabledStyle(BuildContext context) => TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal);
disabledStyle() => TextStyle(
color: Colors.grey,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal);
}
class MenuEntryDivider<T> extends MenuEntryBase<T> {
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return [
mod_menu.PopupMenuDivider(
height: conf.dividerHeight,
)
];
}
}
class MenuEntryRadioOption {
String text;
String value;
bool dismissOnClicked;
RxBool? enabled;
DismissCallback? dismissCallback;
MenuEntryRadioOption({
required this.text,
required this.value,
this.dismissOnClicked = false,
this.enabled,
this.dismissCallback,
});
}
typedef RadioOptionsGetter = List<MenuEntryRadioOption> Function();
typedef RadioCurOptionGetter = Future<String> Function();
typedef RadioOptionSetter = Future<void> Function(
String oldValue, String newValue);
class MenuEntryRadioUtils<T> {}
class MenuEntryRadios<T> extends MenuEntryBase<T> {
final String text;
final RadioOptionsGetter optionsGetter;
final RadioCurOptionGetter curOptionGetter;
final RadioOptionSetter optionSetter;
final RxString _curOption = "".obs;
final EdgeInsets? padding;
MenuEntryRadios({
required this.text,
required this.optionsGetter,
required this.curOptionGetter,
required this.optionSetter,
this.padding,
dismissOnClicked = false,
dismissCallback,
RxBool? enabled,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
) {
() async {
_curOption.value = await curOptionGetter();
}();
}
List<MenuEntryRadioOption> get options => optionsGetter();
RxString get curOption => _curOption;
setOption(String option) async {
await optionSetter(_curOption.value, option);
if (_curOption.value != option) {
final opt = await curOptionGetter();
if (_curOption.value != opt) {
_curOption.value = opt;
}
}
}
mod_menu.PopupMenuEntry<T> _buildMenuItem(
BuildContext context, MenuConfig conf, MenuEntryRadioOption opt) {
Widget getTextChild() {
final enabledTextChild = Text(
opt.text,
style: enabledStyle(context),
);
final disabledTextChild = Text(
opt.text,
style: disabledStyle(),
);
if (opt.enabled == null) {
return enabledTextChild;
} else {
return Obx(
() => opt.enabled!.isTrue ? enabledTextChild : disabledTextChild);
}
}
final child = Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
constraints:
BoxConstraints(minHeight: conf.height, maxHeight: conf.height),
child: Row(
children: [
getTextChild(),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() => opt.value == curOption.value
? IconButton(
padding:
const EdgeInsets.fromLTRB(8.0, 0.0, 8.0, 0.0),
hoverColor: Colors.transparent,
focusColor: Colors.transparent,
onPressed: () {},
icon: Icon(
Icons.check,
color: (opt.enabled ?? true.obs).isTrue
? conf.commonColor
: Colors.grey,
))
: const SizedBox.shrink()),
))),
],
),
);
onPressed() {
if (opt.dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
if (opt.dismissCallback != null) {
opt.dismissCallback!();
}
}
setOption(opt.value);
}
return mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: Container(
width: conf.boxWidth,
child: opt.enabled == null
? TextButton(
child: child,
onPressed: onPressed,
)
: Obx(() => TextButton(
child: child,
onPressed: opt.enabled!.isTrue ? onPressed : null,
)),
),
);
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return options.map((opt) => _buildMenuItem(context, conf, opt)).toList();
}
}
class MenuEntrySubRadios<T> extends MenuEntryBase<T> {
final String text;
final RadioOptionsGetter optionsGetter;
final RadioCurOptionGetter curOptionGetter;
final RadioOptionSetter optionSetter;
final RxString _curOption = "".obs;
final EdgeInsets? padding;
MenuEntrySubRadios({
required this.text,
required this.optionsGetter,
required this.curOptionGetter,
required this.optionSetter,
this.padding,
dismissOnClicked = false,
RxBool? enabled,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
) {
() async {
_curOption.value = await curOptionGetter();
}();
}
List<MenuEntryRadioOption> get options => optionsGetter();
RxString get curOption => _curOption;
setOption(String option) async {
await optionSetter(_curOption.value, option);
if (_curOption.value != option) {
final opt = await curOptionGetter();
if (_curOption.value != opt) {
_curOption.value = opt;
}
}
}
mod_menu.PopupMenuEntry<T> _buildSecondMenu(
BuildContext context, MenuConfig conf, MenuEntryRadioOption opt) {
return mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: Container(
width: conf.boxWidth,
child: TextButton(
child: Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints(
minHeight: conf.height, maxHeight: conf.height),
child: Row(
children: [
Text(
opt.text,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() => opt.value == curOption.value
? IconButton(
padding: EdgeInsets.zero,
hoverColor: Colors.transparent,
focusColor: Colors.transparent,
onPressed: () {},
icon: Icon(
Icons.check,
color: conf.commonColor,
))
: const SizedBox.shrink())),
)),
],
),
),
onPressed: () {
if (opt.dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
if (opt.dismissCallback != null) {
opt.dismissCallback!();
}
}
setOption(opt.value);
},
)),
);
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return [
PopupMenuChildrenItem(
enabled: super.enabled,
padding: padding,
height: conf.height,
itemBuilder: (BuildContext context) =>
options.map((opt) => _buildSecondMenu(context, conf, opt)).toList(),
child: Row(children: [
const SizedBox(width: MenuConfig.midPadding),
Text(
text,
style: TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Icon(
Icons.keyboard_arrow_right,
color: conf.commonColor,
),
))
]),
)
];
}
}
enum SwitchType {
sswitch,
scheckbox,
}
typedef SwitchGetter = Future<bool> Function();
typedef SwitchSetter = Future<void> Function(bool);
abstract class MenuEntrySwitchBase<T> extends MenuEntryBase<T> {
final SwitchType switchType;
final String text;
final EdgeInsets? padding;
Rx<TextStyle>? textStyle;
MenuEntrySwitchBase({
required this.switchType,
required this.text,
required dismissOnClicked,
this.textStyle,
this.padding,
RxBool? enabled,
dismissCallback,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
);
bool get isEnabled => enabled?.value ?? true;
RxBool get curOption;
Future<void> setOption(bool? option);
tryPop(BuildContext context) {
if (dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
super.dismissCallback?.call();
}
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
textStyle ??= TextStyle(
color: Theme.of(context).textTheme.titleLarge?.color,
fontSize: MenuConfig.fontSize,
fontWeight: FontWeight.normal)
.obs;
return [
mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: Container(
width: conf.boxWidth,
child: TextButton(
child: Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
height: conf.height,
child: Row(children: [
Obx(() => Text(
text,
style: textStyle!.value,
)),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Transform.scale(
scale: MenuConfig.iconScale,
child: Obx(() {
if (switchType == SwitchType.sswitch) {
return Switch(
value: curOption.value,
onChanged: isEnabled
? (v) {
tryPop(context);
setOption(v);
}
: null,
);
} else {
return Checkbox(
value: curOption.value,
onChanged: isEnabled
? (v) {
tryPop(context);
setOption(v);
}
: null,
);
}
})),
))
])),
onPressed: isEnabled
? () {
tryPop(context);
setOption(!curOption.value);
}
: null,
)),
)
];
}
}
class MenuEntrySwitch<T> extends MenuEntrySwitchBase<T> {
final SwitchGetter getter;
final SwitchSetter setter;
final RxBool _curOption = false.obs;
MenuEntrySwitch({
required SwitchType switchType,
required String text,
required this.getter,
required this.setter,
Rx<TextStyle>? textStyle,
EdgeInsets? padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
switchType: switchType,
text: text,
textStyle: textStyle,
padding: padding,
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
) {
() async {
_curOption.value = await getter();
}();
}
@override
RxBool get curOption => _curOption;
@override
setOption(bool? option) async {
if (option != null) {
await setter(option);
final opt = await getter();
if (_curOption.value != opt) {
_curOption.value = opt;
}
}
}
}
// Compatible with MenuEntrySwitch, it uses value instead of getter
class MenuEntrySwitchSync<T> extends MenuEntrySwitchBase<T> {
final SwitchSetter setter;
final RxBool _curOption = false.obs;
MenuEntrySwitchSync({
required SwitchType switchType,
required String text,
required bool currentValue,
required this.setter,
Rx<TextStyle>? textStyle,
EdgeInsets? padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
switchType: switchType,
text: text,
textStyle: textStyle,
padding: padding,
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
) {
_curOption.value = currentValue;
}
@override
RxBool get curOption => _curOption;
@override
setOption(bool? option) async {
if (option != null) {
await setter(option);
// Notice: no ensure with getter, best used on menus that are destroyed on click
if (_curOption.value != option) {
_curOption.value = option;
}
}
}
}
typedef Switch2Getter = RxBool Function();
typedef Switch2Setter = Future<void> Function(bool);
class MenuEntrySwitch2<T> extends MenuEntrySwitchBase<T> {
final Switch2Getter getter;
final SwitchSetter setter;
MenuEntrySwitch2({
required SwitchType switchType,
required String text,
required this.getter,
required this.setter,
Rx<TextStyle>? textStyle,
EdgeInsets? padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
switchType: switchType,
text: text,
textStyle: textStyle,
padding: padding,
dismissOnClicked: dismissOnClicked,
dismissCallback: dismissCallback,
);
@override
RxBool get curOption => getter();
@override
setOption(bool? option) async {
if (option != null) {
await setter(option);
}
}
}
class MenuEntrySubMenu<T> extends MenuEntryBase<T> {
final String text;
final List<MenuEntryBase<T>> entries;
final EdgeInsets? padding;
MenuEntrySubMenu({
required this.text,
required this.entries,
this.padding,
RxBool? enabled,
}) : super(enabled: enabled);
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
super.enabled ??= true.obs;
return [
PopupMenuChildrenItem(
enabled: super.enabled,
height: conf.height,
padding: padding,
position: mod_menu.PopupMenuPosition.overSide,
itemBuilder: (BuildContext context) => entries
.map((entry) => entry.build(context, conf))
.expand((i) => i)
.toList(),
child: Row(children: [
const SizedBox(width: MenuConfig.midPadding),
Obx(() => Text(
text,
style: super.enabled!.value
? enabledStyle(context)
: disabledStyle(),
)),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Obx(() => Icon(
Icons.keyboard_arrow_right,
color: super.enabled!.value ? conf.commonColor : Colors.grey,
)),
))
]),
)
];
}
}
class MenuEntryButton<T> extends MenuEntryBase<T> {
final Widget Function(TextStyle? style) childBuilder;
Function() proc;
final EdgeInsets? padding;
MenuEntryButton({
required this.childBuilder,
required this.proc,
this.padding,
dismissOnClicked = false,
RxBool? enabled,
dismissCallback,
}) : super(
dismissOnClicked: dismissOnClicked,
enabled: enabled,
dismissCallback: dismissCallback,
);
Widget _buildChild(BuildContext context, MenuConfig conf) {
super.enabled ??= true.obs;
return Obx(() => Container(
width: conf.boxWidth,
child: TextButton(
onPressed: super.enabled!.value
? () {
if (super.dismissOnClicked && Navigator.canPop(context)) {
Navigator.pop(context);
if (super.dismissCallback != null) {
super.dismissCallback!();
}
}
proc();
}
: null,
child: Container(
padding: padding,
alignment: AlignmentDirectional.centerStart,
constraints:
BoxConstraints(minHeight: conf.height, maxHeight: conf.height),
child: childBuilder(
super.enabled!.value ? enabledStyle(context) : disabledStyle()),
),
)));
}
@override
List<mod_menu.PopupMenuEntry<T>> build(
BuildContext context, MenuConfig conf) {
return [
mod_menu.PopupMenuItem(
padding: EdgeInsets.zero,
height: conf.height,
child: _buildChild(context, conf),
)
];
}
}
class CustomPopupMenuTheme {
static const Color commonColor = MyTheme.accent;
// kMinInteractiveDimension
static const double height = 20.0;
static const double dividerHeight = 3.0;
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/main.dart';
import 'package:get/get.dart';
class RefreshWrapper extends StatefulWidget {
final Widget Function(BuildContext context) builder;
const RefreshWrapper({super.key, required this.builder});
@override
State<RefreshWrapper> createState() => RefreshWrapperState();
static RefreshWrapperState? of(BuildContext context) {
final state = context.findAncestorStateOfType<RefreshWrapperState>();
if (state == null) {
debugPrint(
"RefreshWrapperState not exists in this context, perhaps RefreshWrapper is not exists?");
}
return state;
}
}
class RefreshWrapperState extends State<RefreshWrapper> {
@override
Widget build(BuildContext context) {
return widget.builder(context);
}
rebuild() {
debugPrint("=====Global State Rebuild (win-${kWindowId ?? 'main'})=====");
if (Get.context != null) {
(context as Element).visitChildren(_rebuildElement);
}
// Synchronize the window theme of the system.
updateSystemWindowTheme();
setState(() {});
}
/// set root tree dirty to trigger global rebuild
void _rebuildElement(Element el) {
el.markNeedsBuild();
el.visitChildren(_rebuildElement);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
const sidebarColor = Color(0xFF0C6AF6);
const backgroundStartColor = Color(0xFF0583EA);
const backgroundEndColor = Color(0xFF0697EA);
class DesktopTitleBar extends StatelessWidget {
final Widget? child;
const DesktopTitleBar({Key? key, this.child}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [backgroundStartColor, backgroundEndColor],
stops: [0.0, 1.0]),
),
child: Row(
children: [
Expanded(
child: child ?? Offstage(),
)
],
),
);
}
}
@@ -0,0 +1,267 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
final _isExtracting = false.obs;
void handleUpdate(String releasePageUrl) {
_isExtracting.value = false;
String downloadUrl = releasePageUrl.replaceAll('tag', 'download');
String version = downloadUrl.substring(downloadUrl.lastIndexOf('/') + 1);
final String downloadFile =
bind.mainGetCommonSync(key: 'download-file-$version');
if (downloadFile.startsWith('error:')) {
final error = downloadFile.replaceFirst('error:', '');
msgBox(gFFI.sessionId, 'custom-nocancel-nook-hasclose', 'Error', error,
releasePageUrl, gFFI.dialogManager);
return;
}
downloadUrl = '$downloadUrl/$downloadFile';
SimpleWrapper downloadId = SimpleWrapper('');
SimpleWrapper<VoidCallback> onCanceled = SimpleWrapper(() {});
gFFI.dialogManager.dismissAll();
gFFI.dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Obx(() => Text(translate(_isExtracting.isTrue
? 'Preparing for installation ...'
: 'Downloading {$appName}'))),
content:
UpdateProgress(releasePageUrl, downloadUrl, downloadId, onCanceled)
.marginSymmetric(horizontal: 8)
.paddingOnly(top: 12),
actions: [
if (_isExtracting.isFalse) dialogButton(translate('Cancel'), onPressed: () async {
onCanceled.value();
await bind.mainSetCommon(
key: 'cancel-downloader', value: downloadId.value);
// Wait for the downloader to be removed.
for (int i = 0; i < 10; i++) {
await Future.delayed(const Duration(milliseconds: 300));
final isCanceled = 'error:Downloader not found' ==
await bind.mainGetCommon(
key: 'download-data-${downloadId.value}');
if (isCanceled) {
break;
}
}
close();
}, isOutline: true),
]);
});
}
class UpdateProgress extends StatefulWidget {
final String releasePageUrl;
final String downloadUrl;
final SimpleWrapper downloadId;
final SimpleWrapper onCanceled;
UpdateProgress(
this.releasePageUrl, this.downloadUrl, this.downloadId, this.onCanceled,
{Key? key})
: super(key: key);
@override
State<UpdateProgress> createState() => UpdateProgressState();
}
class UpdateProgressState extends State<UpdateProgress> {
Timer? _timer;
int? _totalSize;
int _downloadedSize = 0;
int _getDataFailedCount = 0;
final String _eventKeyDownloadNewVersion = 'download-new-version';
final String _eventKeyExtractUpdateDmg = 'extract-update-dmg';
@override
void initState() {
super.initState();
widget.onCanceled.value = () {
cancelQueryTimer();
};
platformFFI.registerEventHandler(_eventKeyDownloadNewVersion,
_eventKeyDownloadNewVersion, handleDownloadNewVersion,
replace: true);
bind.mainSetCommon(key: 'download-new-version', value: widget.downloadUrl);
if (isMacOS) {
platformFFI.registerEventHandler(_eventKeyExtractUpdateDmg,
_eventKeyExtractUpdateDmg, handleExtractUpdateDmg,
replace: true);
}
}
@override
void dispose() {
cancelQueryTimer();
platformFFI.unregisterEventHandler(
_eventKeyDownloadNewVersion, _eventKeyDownloadNewVersion);
if (isMacOS) {
platformFFI.unregisterEventHandler(
_eventKeyExtractUpdateDmg, _eventKeyExtractUpdateDmg);
}
super.dispose();
}
void cancelQueryTimer() {
_timer?.cancel();
_timer = null;
}
Future<void> handleDownloadNewVersion(Map<String, dynamic> evt) async {
if (evt.containsKey('id')) {
widget.downloadId.value = evt['id'] as String;
_timer = Timer.periodic(const Duration(milliseconds: 300), (timer) {
_updateDownloadData();
});
} else {
if (evt.containsKey('error')) {
_onError(evt['error'] as String);
} else {
// unreachable
_onError('$evt');
}
}
}
// `isExtractDmg` is true when handling extract-update-dmg event.
// It's a rare case that the dmg file is corrupted and cannot be extracted.
void _onError(String error, {bool isExtractDmg = false}) {
cancelQueryTimer();
debugPrint(
'${isExtractDmg ? "Extract" : "Download"} new version error: $error');
final msgBoxType = 'custom-nocancel-nook-hasclose';
final msgBoxTitle = 'Error';
final msgBoxText = 'download-new-version-failed-tip';
final dialogManager = gFFI.dialogManager;
close() {
dialogManager.dismissAll();
}
jumplink() {
launchUrl(Uri.parse(widget.releasePageUrl));
dialogManager.dismissAll();
}
retry() {
dialogManager.dismissAll();
handleUpdate(widget.releasePageUrl);
}
final List<Widget> buttons = [
dialogButton('Download', onPressed: jumplink),
if (!isExtractDmg) dialogButton('Retry', onPressed: retry),
dialogButton('Close', onPressed: close),
];
dialogManager.dismissAll();
dialogManager.show(
(setState, close, context) => CustomAlertDialog(
title: null,
content: SelectionArea(
child: msgboxContent(msgBoxType, msgBoxTitle, msgBoxText)),
actions: buttons,
),
tag: '$msgBoxType-$msgBoxTitle-$msgBoxTitle',
);
}
void _updateDownloadData() {
String err = '';
String downloadData =
bind.mainGetCommonSync(key: 'download-data-${widget.downloadId.value}');
if (downloadData.startsWith('error:')) {
err = downloadData.substring('error:'.length);
} else {
try {
jsonDecode(downloadData).forEach((key, value) {
if (key == 'total_size') {
if (value != null && value is int) {
_totalSize = value;
}
} else if (key == 'downloaded_size') {
_downloadedSize = value as int;
} else if (key == 'error') {
if (value != null) {
err = value.toString();
}
}
});
} catch (e) {
_getDataFailedCount += 1;
debugPrint(
'Failed to get download data ${widget.downloadUrl}, error $e');
if (_getDataFailedCount > 3) {
err = e.toString();
}
}
}
if (err != '') {
_onError(err);
} else {
if (_totalSize != null && _downloadedSize >= _totalSize!) {
cancelQueryTimer();
bind.mainSetCommon(
key: 'remove-downloader', value: widget.downloadId.value);
if (_totalSize == 0) {
_onError('The download file size is 0.');
} else {
setState(() {});
if (isMacOS) {
bind.mainSetCommon(
key: 'extract-update-dmg', value: widget.downloadUrl);
_isExtracting.value = true;
} else {
updateMsgBox();
}
}
} else {
setState(() {});
}
}
}
void updateMsgBox() {
msgBox(
gFFI.sessionId,
'custom-nocancel',
'{$appName} Update',
'{$appName}-to-update-tip',
'',
gFFI.dialogManager,
onSubmit: () {
debugPrint('Downloaded, update to new version now');
bind.mainSetCommon(key: 'update-me', value: widget.downloadUrl);
},
submitTimeout: 5,
);
}
Future<void> handleExtractUpdateDmg(Map<String, dynamic> evt) async {
_isExtracting.value = false;
if (evt.containsKey('err') && (evt['err'] as String).isNotEmpty) {
_onError(evt['err'] as String, isExtractDmg: true);
} else {
updateMsgBox();
}
}
@override
Widget build(BuildContext context) {
getValue() => _totalSize == null
? 0.0
: (_totalSize == 0 ? 1.0 : _downloadedSize / _totalSize!);
return LinearProgressIndicator(
value: _isExtracting.isTrue ? null : getValue(),
minHeight: 20,
borderRadius: BorderRadius.circular(5),
backgroundColor: Colors.grey[300],
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
);
}
}