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
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
class MobileCustomScaleControls extends StatefulWidget {
final FFI ffi;
final ValueChanged<int>? onChanged;
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
@override
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
}
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
@override
FFI get ffi => widget.ffi;
@override
ValueChanged<int>? get onScaleChanged => widget.onChanged;
@override
Widget build(BuildContext context) {
// Smaller button size for mobile
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
final sliderControl = Slider(
value: scalePos,
min: 0.0,
max: 1.0,
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
label: '$scaleValue%',
onChanged: onSliderChanged,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${translate("Scale custom")}: $scaleValue%',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.remove),
tooltip: translate('Decrease'),
onPressed: () => nudgeScale(-1),
),
Expanded(child: sliderControl),
IconButton(
iconSize: 20,
padding: const EdgeInsets.all(4),
constraints: smallBtnConstraints,
icon: const Icon(Icons.add),
tooltip: translate('Increase'),
onPressed: () => nudgeScale(1),
),
],
),
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
const _deployDialogTag = 'android-deploy-device';
void showDeployPromptDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
gFFI.dialogManager.show<bool>((setState, close, context) {
submit() => close(true);
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Text(translate("server_requires_deployment_tip")),
actions: [
dialogButton("Cancel", onPressed: close, isOutline: true),
dialogButton("OK", onPressed: submit),
],
onSubmit: submit,
onCancel: close,
);
}, tag: _deployDialogTag).then((deploy) {
if (deploy == true) {
showDeployDialog();
}
});
}
void showDeployDialog() {
gFFI.dialogManager.dismissByTag(_deployDialogTag);
final tokenController = TextEditingController();
final idController = TextEditingController();
var errorText = "";
var isInProgress = false;
gFFI.dialogManager.show((setState, close, context) {
submit() async {
if (isInProgress) return;
final token = tokenController.text.trim();
if (token.isEmpty) {
setState(() {
errorText = translate("token is required!");
});
return;
}
setState(() {
errorText = "";
isInProgress = true;
});
String res;
try {
res = await bind.mainDeployDevice(
token: token, id: idController.text.trim());
} catch (e) {
setState(() {
errorText = translate(e.toString());
isInProgress = false;
});
return;
}
if (res.isEmpty) {
close();
await gFFI.serverModel.fetchID();
showToast(translate("Successful"));
} else {
setState(() {
errorText = translate(res.toString());
isInProgress = false;
});
}
}
return CustomAlertDialog(
title: Text(translate("Deploy")),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: tokenController,
decoration: InputDecoration(labelText: translate("API Token")),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofocus: true,
).workaroundFreezeLinuxMint(),
TextField(
controller: idController,
decoration:
InputDecoration(labelText: translate("Custom ID (optional)")),
).workaroundFreezeLinuxMint(),
if (errorText.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: SelectableText(
errorText,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
).paddingOnly(top: 8),
),
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
],
),
actions: [
dialogButton("Cancel",
onPressed: isInProgress ? null : close, isOutline: true),
dialogButton("OK", onPressed: isInProgress ? null : submit),
],
onSubmit: submit,
onCancel: isInProgress ? null : close,
);
}, tag: _deployDialogTag);
}
+234
View File
@@ -0,0 +1,234 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/common/widgets/toolbar.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../models/platform_model.dart';
void _showSuccess() {
showToast(translate("Successful"));
}
void setTemporaryPasswordLengthDialog(
OverlayDialogManager dialogManager) async {
List<String> lengths = ['6', '8', '10'];
String length = await bind.mainGetOption(key: "temporary-password-length");
var index = lengths.indexOf(length);
if (index < 0) index = 0;
length = lengths[index];
dialogManager.show((setState, close, context) {
setLength(newValue) {
final oldValue = length;
if (oldValue == newValue) return;
setState(() {
length = newValue;
});
bind.mainSetOption(key: "temporary-password-length", value: newValue);
bind.mainUpdateTemporaryPassword();
Future.delayed(Duration(milliseconds: 200), () {
close();
_showSuccess();
});
}
return CustomAlertDialog(
title: Text(translate("Set one-time password length")),
content: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: lengths
.map(
(value) => Row(
children: [
Text(value),
Radio(
value: value, groupValue: length, onChanged: setLength),
],
),
)
.toList()),
);
}, backDismiss: true, clickMaskDismiss: true);
}
void showServerSettings(OverlayDialogManager dialogManager,
void Function(VoidCallback) setState) async {
Map<String, dynamic> options = {};
try {
options = jsonDecode(await bind.mainGetOptions());
} catch (e) {
print("Invalid server config: $e");
}
showServerSettingsWithValue(
ServerConfig.fromOptions(options), dialogManager, setState);
}
void showServerSettingsWithValue(
ServerConfig serverConfig,
OverlayDialogManager dialogManager,
void Function(VoidCallback)? upSetState) async {
var isInProgress = false;
final idCtrl = TextEditingController(text: serverConfig.idServer);
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
final apiCtrl = TextEditingController(text: serverConfig.apiServer);
final keyCtrl = TextEditingController(text: serverConfig.key);
RxString idServerMsg = ''.obs;
RxString relayServerMsg = ''.obs;
RxString apiServerMsg = ''.obs;
final controllers = [idCtrl, relayCtrl, apiCtrl, keyCtrl];
final errMsgs = [
idServerMsg,
relayServerMsg,
apiServerMsg,
];
dialogManager.show((setState, close, context) {
Future<bool> submit() async {
setState(() {
isInProgress = true;
});
bool ret = await setServerConfig(
null,
errMsgs,
ServerConfig(
idServer: idCtrl.text.trim(),
relayServer: relayCtrl.text.trim(),
apiServer: apiCtrl.text.trim(),
key: keyCtrl.text.trim()));
setState(() {
isInProgress = false;
});
return ret;
}
Widget buildField(
String label, TextEditingController controller, String errorMsg,
{String? Function(String?)? validator, bool autofocus = false}) {
if (isDesktop || isWeb) {
return Row(
children: [
SizedBox(
width: 120,
child: Text(label),
),
SizedBox(width: 8),
Expanded(
child: TextFormField(
controller: controller,
decoration: InputDecoration(
errorText: errorMsg.isEmpty ? null : errorMsg,
contentPadding:
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
),
validator: validator,
autofocus: autofocus,
).workaroundFreezeLinuxMint(),
),
],
);
}
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
errorText: errorMsg.isEmpty ? null : errorMsg,
),
validator: validator,
).workaroundFreezeLinuxMint();
}
return CustomAlertDialog(
title: Row(
children: [
Expanded(child: Text(translate('ID/Relay Server'))),
...ServerConfigImportExportWidgets(controllers, errMsgs),
],
),
content: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 500),
child: Form(
child: Obx(() => Column(
mainAxisSize: MainAxisSize.min,
children: [
buildField(translate('ID Server'), idCtrl, idServerMsg.value,
autofocus: true),
SizedBox(height: 8),
if (!isIOS && !isWeb) ...[
buildField(translate('Relay Server'), relayCtrl,
relayServerMsg.value),
SizedBox(height: 8),
],
buildField(
translate('API Server'),
apiCtrl,
apiServerMsg.value,
validator: (v) {
if (v != null && v.isNotEmpty) {
if (!(v.startsWith('http://') ||
v.startsWith("https://"))) {
return translate("invalid_http");
}
}
return null;
},
),
SizedBox(height: 8),
buildField('Key', keyCtrl, ''),
if (isInProgress)
Padding(
padding: EdgeInsets.only(top: 8),
child: LinearProgressIndicator(),
),
],
)),
),
),
actions: [
dialogButton('Cancel', onPressed: () {
close();
}, isOutline: true),
dialogButton(
'OK',
onPressed: () async {
if (await submit()) {
close();
showToast(translate('Successful'));
upSetState?.call(() {});
} else {
showToast(translate('Failed'));
}
},
),
],
);
});
}
void setPrivacyModeDialog(
OverlayDialogManager dialogManager,
List<TToggleMenu> privacyModeList,
RxString privacyModeState,
) async {
dialogManager.dismissAll();
dialogManager.show((setState, close, context) {
return CustomAlertDialog(
title: Text(translate('Privacy mode')),
content: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: privacyModeList
.map((value) => CheckboxListTile(
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
title: value.child,
value: value.value,
onChanged: value.onChanged,
))
.toList()),
);
}, backDismiss: true, clickMaskDismiss: true);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,905 @@
// These floating mouse widgets are used to simulate a physical mouse
// when "mobile" -> "desktop" in mouse mode.
// This file does not contain whole mouse widgets, it only contains
// parts that help to control, such as wheel scroll and wheel button.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/common/widgets/remote_input.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
// Used for the wheel button and wheel scroll widgets
const double _kSpaceToHorizontalEdge = 25;
const double _wheelWidth = 50;
const double _wheelHeight = 162;
// Used for the left/right button widgets
const double _kSpaceToVerticalEdge = 15;
const double _kSpaceBetweenLeftRightButtons = 40;
const double _kLeftRightButtonWidth = 55;
const double _kLeftRightButtonHeight = 40;
const double _kBorderWidth = 1;
final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7);
final Color _kDefaultColor = Colors.black.withOpacity(0.4);
final Color _kTapDownColor = Colors.blue.withOpacity(0.7);
final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9);
const int _kInputTimerIntervalMillis = 100;
class FloatingMouseWidgets extends StatefulWidget {
final FFI ffi;
const FloatingMouseWidgets({
super.key,
required this.ffi,
});
@override
State<FloatingMouseWidgets> createState() => _FloatingMouseWidgetsState();
}
class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
InputModel get _inputModel => widget.ffi.inputModel;
CursorModel get _cursorModel => widget.ffi.cursorModel;
late final VirtualMouseMode _virtualMouseMode;
@override
void initState() {
super.initState();
_virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode;
_virtualMouseMode.addListener(_onVirtualMouseModeChanged);
_cursorModel.blockEvents = false;
isSpecialHoldDragActive = false;
}
void _onVirtualMouseModeChanged() {
if (mounted) {
setState(() {});
}
}
@override
void dispose() {
_virtualMouseMode.removeListener(_onVirtualMouseModeChanged);
super.dispose();
_cursorModel.blockEvents = false;
isSpecialHoldDragActive = false;
}
@override
Widget build(BuildContext context) {
final virtualMouseMode = _virtualMouseMode;
if (!virtualMouseMode.showVirtualMouse) {
return const Offstage();
}
return Stack(
children: [
FloatingWheel(
inputModel: _inputModel,
cursorModel: _cursorModel,
),
if (virtualMouseMode.showVirtualJoystick)
VirtualJoystick(
cursorModel: _cursorModel,
inputModel: _inputModel,
),
FloatingLeftRightButton(
isLeft: true,
inputModel: _inputModel,
cursorModel: _cursorModel,
),
FloatingLeftRightButton(
isLeft: false,
inputModel: _inputModel,
cursorModel: _cursorModel,
),
],
);
}
}
class FloatingWheel extends StatefulWidget {
final InputModel inputModel;
final CursorModel cursorModel;
const FloatingWheel(
{super.key, required this.inputModel, required this.cursorModel});
@override
State<FloatingWheel> createState() => _FloatingWheelState();
}
class _FloatingWheelState extends State<FloatingWheel> {
Offset _position = Offset.zero;
bool _isInitialized = false;
Rect? _lastBlockedRect;
bool _isUpDown = false;
bool _isMidDown = false;
bool _isDownDown = false;
Orientation? _previousOrientation;
Timer? _scrollTimer;
InputModel get _inputModel => widget.inputModel;
CursorModel get _cursorModel => widget.cursorModel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_resetPosition();
});
}
void _resetPosition() {
final size = MediaQuery.of(context).size;
setState(() {
_position = Offset(
size.width - _wheelWidth - _kSpaceToHorizontalEdge,
(size.height - _wheelHeight) / 2,
);
_isInitialized = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
void _updateBlockedRect() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
final newRect =
Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight);
_cursorModel.addBlockedRect(newRect);
_lastBlockedRect = newRect;
}
@override
void dispose() {
_scrollTimer?.cancel();
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentOrientation = MediaQuery.of(context).orientation;
if (_previousOrientation != null &&
_previousOrientation != currentOrientation) {
_resetPosition();
}
_previousOrientation = currentOrientation;
}
Widget _buildUpDownButton(
void Function(PointerDownEvent) onPointerDown,
void Function(PointerUpEvent) onPointerUp,
void Function(PointerCancelEvent) onPointerCancel,
bool Function() flagGetter,
BorderRadiusGeometry borderRadius,
IconData iconData) {
return Listener(
onPointerDown: onPointerDown,
onPointerUp: onPointerUp,
onPointerCancel: onPointerCancel,
child: Container(
width: _wheelWidth,
height: 55,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.all(
color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor,
width: 1),
borderRadius: borderRadius,
),
child: Icon(iconData, color: _kDefaultBorderColor, size: 32),
),
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx,
top: _position.dy,
child: _buildWidget(context),
);
}
Widget _buildWidget(BuildContext context) {
return Container(
width: _wheelWidth,
height: _wheelHeight,
child: Column(
children: [
_buildUpDownButton(
(event) {
setState(() {
_isUpDown = true;
});
_startScrollTimer(1);
},
(event) {
setState(() {
_isUpDown = false;
});
_stopScrollTimer();
},
(event) {
setState(() {
_isUpDown = false;
});
_stopScrollTimer();
},
() => _isUpDown,
BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)),
Icons.keyboard_arrow_up,
),
Listener(
onPointerDown: (event) {
setState(() {
_isMidDown = true;
});
_inputModel.tapDown(MouseButtons.wheel);
},
onPointerUp: (event) {
setState(() {
_isMidDown = false;
});
_inputModel.tapUp(MouseButtons.wheel);
},
onPointerCancel: (event) {
setState(() {
_isMidDown = false;
});
_inputModel.tapUp(MouseButtons.wheel);
},
child: Container(
width: _wheelWidth,
height: 52,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.symmetric(
vertical: BorderSide(
color:
_isMidDown ? _kTapDownColor : _kDefaultBorderColor,
width: _kBorderWidth)),
),
child: Center(
child: Container(
width: _wheelWidth - 10,
height: _wheelWidth - 10,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 18,
height: 2,
color: _kDefaultBorderColor,
),
SizedBox(height: 6),
Container(
width: 24,
height: 2,
color: _kDefaultBorderColor,
),
SizedBox(height: 6),
Container(
width: 18,
height: 2,
color: _kDefaultBorderColor,
),
],
),
),
),
),
),
),
_buildUpDownButton(
(event) {
setState(() {
_isDownDown = true;
});
_startScrollTimer(-1);
},
(event) {
setState(() {
_isDownDown = false;
});
_stopScrollTimer();
},
(event) {
setState(() {
_isDownDown = false;
});
_stopScrollTimer();
},
() => _isDownDown,
BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)),
Icons.keyboard_arrow_down,
),
],
),
);
}
void _startScrollTimer(int direction) {
_scrollTimer?.cancel();
_inputModel.scroll(direction);
_scrollTimer = Timer.periodic(
Duration(milliseconds: _kInputTimerIntervalMillis), (timer) {
_inputModel.scroll(direction);
});
}
void _stopScrollTimer() {
_scrollTimer?.cancel();
_scrollTimer = null;
}
}
class FloatingLeftRightButton extends StatefulWidget {
final bool isLeft;
final InputModel inputModel;
final CursorModel cursorModel;
const FloatingLeftRightButton(
{super.key,
required this.isLeft,
required this.inputModel,
required this.cursorModel});
@override
State<FloatingLeftRightButton> createState() =>
_FloatingLeftRightButtonState();
}
class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
Offset _position = Offset.zero;
bool _isInitialized = false;
bool _isDown = false;
Rect? _lastBlockedRect;
Orientation? _previousOrientation;
Offset _preSavedPos = Offset.zero;
// Gesture ambiguity resolution
Timer? _tapDownTimer;
final Duration _pressTimeout = const Duration(milliseconds: 200);
bool _isDragging = false;
bool get _isLeft => widget.isLeft;
InputModel get _inputModel => widget.inputModel;
CursorModel get _cursorModel => widget.cursorModel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final currentOrientation = MediaQuery.of(context).orientation;
_previousOrientation = currentOrientation;
_resetPosition(currentOrientation);
});
}
@override
void dispose() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
_tapDownTimer?.cancel();
_trySavePosition();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentOrientation = MediaQuery.of(context).orientation;
if (_previousOrientation == null ||
_previousOrientation != currentOrientation) {
_resetPosition(currentOrientation);
}
_previousOrientation = currentOrientation;
}
double _getOffsetX(double w) {
if (_isLeft) {
return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) *
0.5;
} else {
return (w + _kSpaceBetweenLeftRightButtons) * 0.5;
}
}
String _getPositionKey(Orientation ori) {
final strLeftRight = _isLeft ? 'l' : 'r';
final strOri = ori == Orientation.landscape ? 'l' : 'p';
return '$strLeftRight$strOri-mouse-btn-pos';
}
static Offset? _loadPositionFromString(String s) {
if (s.isEmpty) {
return null;
}
try {
final m = jsonDecode(s);
return Offset(m['x'], m['y']);
} catch (e) {
debugPrintStack(label: 'Failed to load position "$s" $e');
return null;
}
}
void _trySavePosition() {
if (_previousOrientation == null) return;
if (((_position - _preSavedPos)).distanceSquared < 0.1) return;
final pos = jsonEncode({
'x': _position.dx,
'y': _position.dy,
});
bind.setLocalFlutterOption(
k: _getPositionKey(_previousOrientation!), v: pos);
_preSavedPos = _position;
}
void _restorePosition(Orientation ori) {
final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori));
final pos = _loadPositionFromString(ps);
if (pos == null) {
final size = MediaQuery.of(context).size;
_position = Offset(_getOffsetX(size.width),
size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight);
} else {
_position = pos;
_preSavedPos = pos;
}
}
void _resetPosition(Orientation ori) {
setState(() {
_restorePosition(ori);
_isInitialized = true;
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
void _updateBlockedRect() {
if (_lastBlockedRect != null) {
_cursorModel.removeBlockedRect(_lastBlockedRect!);
}
final newRect = Rect.fromLTWH(_position.dx, _position.dy,
_kLeftRightButtonWidth, _kLeftRightButtonHeight);
_cursorModel.addBlockedRect(newRect);
_lastBlockedRect = newRect;
}
void _onMoveUpdateDelta(Offset delta) {
final context = this.context;
final size = MediaQuery.of(context).size;
Offset newPosition = _position + delta;
double minX = _kSpaceToHorizontalEdge;
double minY = _kSpaceToVerticalEdge;
double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge;
double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge;
newPosition = Offset(
newPosition.dx.clamp(minX, maxX),
newPosition.dy.clamp(minY, maxY),
);
final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) &&
isDoubleEqual(newPosition.dy, _position.dy));
setState(() {
_position = newPosition;
});
if (isPositionChanged) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _updateBlockedRect();
});
}
}
void _onBodyPointerMoveUpdate(PointerMoveEvent event) {
_cursorModel.blockEvents = true;
// If move, it's a drag, not a tap.
_isDragging = true;
// Cancel the timer to prevent it from being recognized as a tap/hold.
_tapDownTimer?.cancel();
_tapDownTimer = null;
_onMoveUpdateDelta(event.delta);
}
Widget _buildButtonIcon() {
final double w = _kLeftRightButtonWidth * 0.45;
final double h = _kLeftRightButtonHeight * 0.75;
final double borderRadius = w * 0.5;
final double quarterCircleRadius = borderRadius * 0.9;
return Stack(
children: [
Container(
width: w,
height: h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225),
color: Colors.white,
),
),
Positioned(
left: _isLeft ? quarterCircleRadius * 0.25 : null,
right: _isLeft ? null : quarterCircleRadius * 0.25,
top: quarterCircleRadius * 0.25,
child: CustomPaint(
size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2),
painter: _QuarterCirclePainter(
color: _kDefaultColor,
isLeft: _isLeft,
radius: quarterCircleRadius,
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx,
top: _position.dy,
// We can't use the GestureDetector here, because `onTapDown` may be
// triggered sometimes when dragging.
child: Listener(
onPointerMove: _onBodyPointerMoveUpdate,
onPointerDown: (event) async {
_isDragging = false;
setState(() {
_isDown = true;
});
// Start a timer. If it fires, it's a hold.
_tapDownTimer?.cancel();
_tapDownTimer = Timer(_pressTimeout, () {
isSpecialHoldDragActive = true;
() async {
await _cursorModel.syncCursorPosition();
await _inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
}();
_tapDownTimer = null;
});
},
onPointerUp: (event) {
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
// If timer is active, it's a quick tap.
if (_tapDownTimer != null) {
_tapDownTimer!.cancel();
_tapDownTimer = null;
// Fire tap down and up quickly.
_inputModel
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right)
.then(
(_) => Future.delayed(const Duration(milliseconds: 50), () {
_inputModel.tapUp(
_isLeft ? MouseButtons.left : MouseButtons.right);
}));
} else {
// If it's not a quick tap, it could be a hold or drag.
// If it was a hold, isSpecialHoldDragActive is true.
if (isSpecialHoldDragActive) {
_inputModel
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
}
if (_isDragging) {
_trySavePosition();
}
isSpecialHoldDragActive = false;
},
onPointerCancel: (event) {
_cursorModel.blockEvents = false;
setState(() {
_isDown = false;
});
_tapDownTimer?.cancel();
_tapDownTimer = null;
if (isSpecialHoldDragActive) {
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
}
isSpecialHoldDragActive = false;
if (_isDragging) {
_trySavePosition();
}
},
child: Container(
width: _kLeftRightButtonWidth,
height: _kLeftRightButtonHeight,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _kDefaultColor,
border: Border.all(
color: _isDown ? _kTapDownColor : _kDefaultBorderColor,
width: _kBorderWidth),
borderRadius: _isLeft
? BorderRadius.horizontal(
left: Radius.circular(_kLeftRightButtonHeight * 0.5))
: BorderRadius.horizontal(
right: Radius.circular(_kLeftRightButtonHeight * 0.5)),
),
child: _buildButtonIcon(),
),
),
);
}
}
class _QuarterCirclePainter extends CustomPainter {
final Color color;
final bool isLeft;
final double radius;
_QuarterCirclePainter(
{required this.color, required this.isLeft, required this.radius});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2);
if (isLeft) {
canvas.drawArc(rect, -pi, pi / 2, true, paint);
} else {
canvas.drawArc(rect, -pi / 2, pi / 2, true, paint);
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
// Virtual joystick can send either absolute movement (via updatePan)
// or relative movement (via sendMobileRelativeMouseMove) depending on the
// InputModel.relativeMouseMode setting.
class VirtualJoystick extends StatefulWidget {
final CursorModel cursorModel;
final InputModel inputModel;
const VirtualJoystick({
super.key,
required this.cursorModel,
required this.inputModel,
});
@override
State<VirtualJoystick> createState() => _VirtualJoystickState();
}
class _VirtualJoystickState extends State<VirtualJoystick> {
Offset _position = Offset.zero;
bool _isInitialized = false;
Offset _offset = Offset.zero;
final double _joystickRadius = 50.0;
final double _thumbRadius = 20.0;
final double _moveStep = 3.0;
final double _speed = 1.0;
/// Scale factor for relative mouse movement sensitivity.
/// Higher values result in faster cursor movement on the remote machine.
static const double _kRelativeMouseScale = 3.0;
// One-shot timer to detect a drag gesture
Timer? _dragStartTimer;
// Periodic timer for continuous movement
Timer? _continuousMoveTimer;
Size? _lastScreenSize;
bool _isPressed = false;
/// Check if relative mouse mode is enabled.
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
@override
void initState() {
super.initState();
widget.cursorModel.blockEvents = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
_lastScreenSize = MediaQuery.of(context).size;
_resetPosition();
});
}
@override
void dispose() {
_stopSendEventTimer();
widget.cursorModel.blockEvents = false;
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final currentScreenSize = MediaQuery.of(context).size;
if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) {
_resetPosition();
}
_lastScreenSize = currentScreenSize;
}
void _resetPosition() {
final size = MediaQuery.of(context).size;
setState(() {
_position = Offset(
_kSpaceToHorizontalEdge + _joystickRadius,
size.height * 0.5 + _joystickRadius * 1.5,
);
_isInitialized = true;
});
}
Offset _offsetToPanDelta(Offset offset) {
return Offset(
offset.dx / _joystickRadius,
offset.dy / _joystickRadius,
);
}
/// Send movement delta to remote machine.
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
void _sendMovement(Offset delta) {
if (_useRelativeMouse) {
widget.inputModel.sendMobileRelativeMouseMove(
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
} else {
// In absolute mode, use cursorModel.updatePan which tracks position.
widget.cursorModel.updatePan(delta, Offset.zero, false);
}
}
void _stopSendEventTimer() {
_dragStartTimer?.cancel();
_continuousMoveTimer?.cancel();
_dragStartTimer = null;
_continuousMoveTimer = null;
}
@override
Widget build(BuildContext context) {
if (!_isInitialized) {
return Positioned(child: Offstage());
}
return Positioned(
left: _position.dx - _joystickRadius,
top: _position.dy - _joystickRadius,
child: GestureDetector(
onPanStart: (details) {
setState(() {
_isPressed = true;
});
widget.cursorModel.blockEvents = true;
_updateOffset(details.localPosition);
// 1. Send a single, small pan event immediately for responsiveness.
// The movement is small for a gentle start.
final initialDelta = _offsetToPanDelta(_offset);
if (initialDelta.distance > 0) {
_sendMovement(initialDelta);
}
// 2. Start a one-shot timer to check if the user is holding for a drag.
_dragStartTimer?.cancel();
_dragStartTimer = Timer(const Duration(milliseconds: 120), () {
// 3. If the timer fires, it's a drag. Start the continuous movement timer.
_continuousMoveTimer?.cancel();
_continuousMoveTimer =
periodic_immediate(const Duration(milliseconds: 20), () async {
if (_offset != Offset.zero) {
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
}
});
});
},
onPanUpdate: (details) {
_updateOffset(details.localPosition);
},
onPanEnd: (details) {
setState(() {
_offset = Offset.zero;
_isPressed = false;
});
widget.cursorModel.blockEvents = false;
// 4. Critical step: On pan end, cancel all timers.
// If it was a flick, this cancels the drag detection before it fires.
// If it was a drag, this stops the continuous movement.
_stopSendEventTimer();
},
child: CustomPaint(
size: Size(_joystickRadius * 2, _joystickRadius * 2),
painter: _JoystickPainter(
_offset, _joystickRadius, _thumbRadius, _isPressed),
),
),
);
}
void _updateOffset(Offset localPosition) {
final center = Offset(_joystickRadius, _joystickRadius);
final offset = localPosition - center;
final distance = offset.distance;
if (distance <= _joystickRadius) {
setState(() {
_offset = offset;
});
} else {
final clampedOffset = offset / distance * _joystickRadius;
setState(() {
_offset = clampedOffset;
});
}
}
}
class _JoystickPainter extends CustomPainter {
final Offset _offset;
final double _joystickRadius;
final double _thumbRadius;
final bool _isPressed;
_JoystickPainter(
this._offset, this._joystickRadius, this._thumbRadius, this._isPressed);
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final joystickColor = _kDefaultColor;
final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor;
final thumbColor = _kWidgetHighlightColor;
final joystickPaint = Paint()
..color = joystickColor
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
final thumbPaint = Paint()
..color = thumbColor
..style = PaintingStyle.fill;
// Draw joystick base and border
canvas.drawCircle(center, _joystickRadius, joystickPaint);
canvas.drawCircle(center, _joystickRadius, borderPaint);
// Draw thumb
final thumbCenter = center + _offset;
canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint);
}
@override
bool shouldRepaint(covariant _JoystickPainter oldDelegate) {
return oldDelegate._offset != _offset ||
oldDelegate._isPressed != _isPressed;
}
}
@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
import 'package:flutter_hbb/common.dart';
import 'package:flutter_hbb/models/input_model.dart';
import 'package:flutter_hbb/models/model.dart';
import 'package:get/get.dart';
import 'package:toggle_switch/toggle_switch.dart';
class GestureIcons {
static const String _family = 'gestureicons';
GestureIcons._();
static const IconData iconMouse = IconData(0xe65c, fontFamily: _family);
static const IconData iconTabletTouch = IconData(0xe9ce, fontFamily: _family);
static const IconData iconGestureFDrag =
IconData(0xe686, fontFamily: _family);
static const IconData iconMobileTouch = IconData(0xe9cd, fontFamily: _family);
static const IconData iconGesturePress =
IconData(0xe66c, fontFamily: _family);
static const IconData iconGestureTap = IconData(0xe66f, fontFamily: _family);
static const IconData iconGesturePinch =
IconData(0xe66a, fontFamily: _family);
static const IconData iconGesturePressHold =
IconData(0xe66b, fontFamily: _family);
static const IconData iconGestureFDragUpDown_ =
IconData(0xe685, fontFamily: _family);
static const IconData iconGestureFTap_ =
IconData(0xe68e, fontFamily: _family);
static const IconData iconGestureFSwipeRight =
IconData(0xe68f, fontFamily: _family);
static const IconData iconGestureFdoubleTap =
IconData(0xe691, fontFamily: _family);
static const IconData iconGestureFThreeFingers =
IconData(0xe687, fontFamily: _family);
}
typedef OnTouchModeChange = void Function(bool);
class GestureHelp extends StatefulWidget {
GestureHelp(
{Key? key,
required this.touchMode,
required this.onTouchModeChange,
required this.virtualMouseMode,
this.inputModel})
: super(key: key);
final bool touchMode;
final OnTouchModeChange onTouchModeChange;
final VirtualMouseMode virtualMouseMode;
final InputModel? inputModel;
@override
State<StatefulWidget> createState() =>
_GestureHelpState(touchMode, virtualMouseMode);
}
class _GestureHelpState extends State<GestureHelp> {
late int _selectedIndex;
late bool _touchMode;
final VirtualMouseMode _virtualMouseMode;
_GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode)
: _virtualMouseMode = virtualMouseMode {
_touchMode = touchMode;
_selectedIndex = _touchMode ? 1 : 0;
}
/// Helper to exit relative mouse mode when certain conditions are met.
/// This reduces code duplication across multiple UI callbacks.
void _exitRelativeMouseModeIf(bool condition) {
if (condition) {
widget.inputModel?.setRelativeMouseMode(false);
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final space = 12.0;
var width = size.width - 2 * space;
final minWidth = 90;
if (size.width > minWidth + 2 * space) {
final n = (size.width / (minWidth + 2 * space)).floor();
width = size.width / n - 2 * space;
}
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ToggleSwitch(
initialLabelIndex: _selectedIndex,
activeFgColor: Colors.white,
inactiveFgColor: Colors.white60,
activeBgColor: [MyTheme.accent],
inactiveBgColor: Theme.of(context).hintColor,
totalSwitches: 2,
minWidth: 150,
fontSize: 15,
iconSize: 18,
labels: [
translate("Mouse mode"),
translate("Touch mode")
],
icons: [Icons.mouse, Icons.touch_app],
onToggle: (index) {
setState(() {
if (_selectedIndex != index) {
_selectedIndex = index ?? 0;
_touchMode = index == 0 ? false : true;
widget.onTouchModeChange(_touchMode);
// Exit relative mouse mode when switching to touch mode
_exitRelativeMouseModeIf(_touchMode);
}
});
},
),
Transform.translate(
offset: const Offset(-10.0, 0.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: _virtualMouseMode.showVirtualMouse,
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode.toggleVirtualMouse();
// Exit relative mouse mode when virtual mouse is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode.showVirtualMouse);
setState(() {});
},
child: Text(translate('Show virtual mouse')),
),
],
),
),
if (_touchMode && _virtualMouseMode.showVirtualMouse)
Padding(
// Indent "Virtual mouse size"
padding: const EdgeInsets.only(left: 24.0),
child: SizedBox(
width: 260,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: 0.0, bottom: 0),
child: Text(translate('Virtual mouse size')),
),
Transform.translate(
offset: Offset(-0.0, -6.0),
child: Row(
children: [
Padding(
padding:
const EdgeInsets.only(left: 0.0),
child: Text(translate('Small')),
),
Expanded(
child: Slider(
value: _virtualMouseMode
.virtualMouseScale,
min: 0.8,
max: 1.8,
divisions: 10,
onChanged: (value) {
_virtualMouseMode
.setVirtualMouseScale(value);
setState(() {});
},
),
),
Padding(
padding:
const EdgeInsets.only(right: 16.0),
child: Text(translate('Large')),
),
],
),
),
],
),
),
),
if (!_touchMode && _virtualMouseMode.showVirtualMouse)
Transform.translate(
offset: const Offset(-10.0, -12.0),
child: Padding(
// Indent "Show virtual joystick"
padding: const EdgeInsets.only(left: 24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value:
_virtualMouseMode.showVirtualJoystick,
onChanged: (value) async {
if (value == null) return;
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
),
InkWell(
onTap: () async {
await _virtualMouseMode
.toggleVirtualJoystick();
// Exit relative mouse mode when joystick is hidden
_exitRelativeMouseModeIf(
!_virtualMouseMode
.showVirtualJoystick);
setState(() {});
},
child: Text(
translate("Show virtual joystick")),
),
],
)),
),
// Relative mouse mode option - only visible when joystick is shown
if (!_touchMode &&
_virtualMouseMode.showVirtualMouse &&
_virtualMouseMode.showVirtualJoystick &&
widget.inputModel != null)
Obx(() => Transform.translate(
offset: const Offset(-10.0, -24.0),
child: Padding(
// Indent further for 'Relative mouse mode'
padding: const EdgeInsets.only(left: 48.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: widget.inputModel!
.relativeMouseMode.value,
onChanged: (value) {
if (value == null) return;
widget.inputModel!
.setRelativeMouseMode(value);
},
),
InkWell(
onTap: () {
widget.inputModel!
.toggleRelativeMouseMode();
},
child: Text(
translate('Relative mouse mode')),
),
],
)),
)),
],
),
),
Container(
child: Wrap(
spacing: space,
runSpacing: 2 * space,
children: _touchMode
? [
GestureInfo(
width,
GestureIcons.iconMobileTouch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.iconGesturePressHold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.iconGestureFSwipeRight,
translate("One-Finger Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.iconGestureFThreeFingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.iconGestureFDrag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.iconGesturePinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
]
: [
GestureInfo(
width,
GestureIcons.iconMobileTouch,
translate("One-Finger Tap"),
translate("Left Mouse")),
GestureInfo(
width,
GestureIcons.iconGesturePressHold,
translate("One-Long Tap"),
translate("Right Mouse")),
GestureInfo(
width,
GestureIcons.iconGestureFSwipeRight,
translate("Double Tap & Move"),
translate("Mouse Drag")),
GestureInfo(
width,
GestureIcons.iconGestureFThreeFingers,
translate("Three-Finger vertically"),
translate("Mouse Wheel")),
GestureInfo(
width,
GestureIcons.iconGestureFDrag,
translate("Two-Finger Move"),
translate("Canvas Move")),
GestureInfo(
width,
GestureIcons.iconGesturePinch,
translate("Pinch to Zoom"),
translate("Canvas Zoom")),
],
)),
],
)));
}
}
class GestureInfo extends StatelessWidget {
const GestureInfo(this.width, this.icon, this.fromText, this.toText,
{Key? key})
: super(key: key);
final String fromText;
final String toText;
final IconData icon;
final double width;
final iconSize = 35.0;
final iconColor = MyTheme.accent;
@override
Widget build(BuildContext context) {
return Container(
width: width,
child: Column(
children: [
Icon(
icon,
size: iconSize,
color: iconColor,
),
SizedBox(height: 6),
Text(fromText,
textAlign: TextAlign.center,
style:
TextStyle(fontSize: 9, color: Theme.of(context).hintColor)),
SizedBox(height: 3),
Text(toText,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).textTheme.bodySmall?.color))
],
));
}
}