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:
@@ -0,0 +1,79 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
typedef EventCallback<Data> = Future<dynamic> Function(Data data);
|
||||
|
||||
abstract class BaseEvent<EventType, Data> {
|
||||
EventType type;
|
||||
Data data;
|
||||
|
||||
/// Constructor.
|
||||
BaseEvent(this.type, this.data);
|
||||
|
||||
/// Consume this event.
|
||||
@visibleForTesting
|
||||
Future<dynamic> consume() async {
|
||||
final cb = findCallback(type);
|
||||
if (cb == null) {
|
||||
return null;
|
||||
} else {
|
||||
return cb(data);
|
||||
}
|
||||
}
|
||||
|
||||
EventCallback<Data>? findCallback(EventType type);
|
||||
}
|
||||
|
||||
abstract class BaseEventLoop<EventType, Data> {
|
||||
final List<BaseEvent<EventType, Data>> _evts = [];
|
||||
Timer? _timer;
|
||||
|
||||
List<BaseEvent<EventType, Data>> get evts => _evts;
|
||||
|
||||
Future<void> onReady() async {
|
||||
// Poll every 100ms.
|
||||
_timer = Timer.periodic(Duration(milliseconds: 100), _handleTimer);
|
||||
}
|
||||
|
||||
/// An Event is about to be consumed.
|
||||
Future<void> onPreConsume(BaseEvent<EventType, Data> evt) async {}
|
||||
/// An Event was consumed.
|
||||
Future<void> onPostConsume(BaseEvent<EventType, Data> evt) async {}
|
||||
/// Events are all handled and cleared.
|
||||
Future<void> onEventsClear() async {}
|
||||
/// Events start to consume.
|
||||
Future<void> onEventsStartConsuming() async {}
|
||||
|
||||
Future<void> _handleTimer(Timer timer) async {
|
||||
if (_evts.isEmpty) {
|
||||
return;
|
||||
}
|
||||
timer.cancel();
|
||||
_timer = null;
|
||||
// Handle the logic.
|
||||
await onEventsStartConsuming();
|
||||
while (_evts.isNotEmpty) {
|
||||
final evt = _evts.first;
|
||||
_evts.remove(evt);
|
||||
await onPreConsume(evt);
|
||||
await evt.consume();
|
||||
await onPostConsume(evt);
|
||||
}
|
||||
await onEventsClear();
|
||||
// Now events are all processed.
|
||||
_timer = Timer.periodic(Duration(milliseconds: 100), _handleTimer);
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
void pushEvent(BaseEvent<EventType, Data> evt) {
|
||||
_evts.add(evt);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_evts.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/platform_model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
export 'package:http/http.dart' show Response;
|
||||
|
||||
enum HttpMethod { get, post, put, delete }
|
||||
|
||||
class HttpService {
|
||||
Future<http.Response> sendRequest(
|
||||
Uri url,
|
||||
HttpMethod method, {
|
||||
Map<String, String>? headers,
|
||||
dynamic body,
|
||||
}) async {
|
||||
headers ??= {'Content-Type': 'application/json'};
|
||||
|
||||
// Use Rust HTTP implementation for non-web platforms for consistency.
|
||||
var useFlutterHttp = (isWeb || kIsWeb);
|
||||
if (!useFlutterHttp) {
|
||||
final enableFlutterHttpOnRust =
|
||||
mainGetLocalBoolOptionSync(kOptionEnableFlutterHttpOnRust);
|
||||
// Use flutter http if:
|
||||
// Not `enableFlutterHttpOnRust` and no proxy is set
|
||||
useFlutterHttp =
|
||||
!(enableFlutterHttpOnRust || await bind.mainGetProxyStatus());
|
||||
}
|
||||
|
||||
if (useFlutterHttp) {
|
||||
return await _pollFlutterHttp(url, method, headers: headers, body: body);
|
||||
}
|
||||
|
||||
String headersJson = jsonEncode(headers);
|
||||
String methodName = method.toString().split('.').last;
|
||||
await bind.mainHttpRequest(
|
||||
url: url.toString(),
|
||||
method: methodName.toLowerCase(),
|
||||
body: body,
|
||||
header: headersJson);
|
||||
|
||||
var resJson = await _pollForResponse(url.toString());
|
||||
return _parseHttpResponse(resJson);
|
||||
}
|
||||
|
||||
Future<http.Response> _pollFlutterHttp(
|
||||
Uri url,
|
||||
HttpMethod method, {
|
||||
Map<String, String>? headers,
|
||||
dynamic body,
|
||||
}) async {
|
||||
var response = http.Response('', 400);
|
||||
|
||||
switch (method) {
|
||||
case HttpMethod.get:
|
||||
response = await http.get(url, headers: headers);
|
||||
break;
|
||||
case HttpMethod.post:
|
||||
response = await http.post(url, headers: headers, body: body);
|
||||
break;
|
||||
case HttpMethod.put:
|
||||
response = await http.put(url, headers: headers, body: body);
|
||||
break;
|
||||
case HttpMethod.delete:
|
||||
response = await http.delete(url, headers: headers, body: body);
|
||||
break;
|
||||
default:
|
||||
throw Exception('Unsupported HTTP method');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<String> _pollForResponse(String url) async {
|
||||
String? responseJson = " ";
|
||||
while (responseJson == " ") {
|
||||
responseJson = await bind.mainGetHttpStatus(url: url);
|
||||
if (responseJson == null) {
|
||||
throw Exception('The HTTP request failed');
|
||||
}
|
||||
if (responseJson == " ") {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
}
|
||||
return responseJson!;
|
||||
}
|
||||
|
||||
http.Response _parseHttpResponse(String responseJson) {
|
||||
try {
|
||||
var parsedJson = jsonDecode(responseJson);
|
||||
String body = parsedJson['body'];
|
||||
Map<String, String> headers = {};
|
||||
for (var key in parsedJson['headers'].keys) {
|
||||
headers[key] = parsedJson['headers'][key];
|
||||
}
|
||||
int statusCode = parsedJson['status_code'];
|
||||
return http.Response(body, statusCode, headers: headers);
|
||||
} catch (e) {
|
||||
print('Failed to parse response\n$responseJson\nError:\n$e');
|
||||
throw Exception('Failed to parse response.\n$responseJson');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<http.Response> get(Uri url, {Map<String, String>? headers}) async {
|
||||
return await HttpService().sendRequest(url, HttpMethod.get, headers: headers);
|
||||
}
|
||||
|
||||
Future<http.Response> post(Uri url,
|
||||
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
|
||||
return await HttpService()
|
||||
.sendRequest(url, HttpMethod.post, body: body, headers: headers);
|
||||
}
|
||||
|
||||
Future<http.Response> put(Uri url,
|
||||
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
|
||||
return await HttpService()
|
||||
.sendRequest(url, HttpMethod.put, body: body, headers: headers);
|
||||
}
|
||||
|
||||
Future<http.Response> delete(Uri url,
|
||||
{Map<String, String>? headers, Object? body, Encoding? encoding}) async {
|
||||
return await HttpService()
|
||||
.sendRequest(url, HttpMethod.delete, body: body, headers: headers);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
|
||||
Future<ui.Image?> decodeImageFromPixels(
|
||||
Uint8List pixels,
|
||||
int width,
|
||||
int height,
|
||||
ui.PixelFormat format, {
|
||||
int? rowBytes,
|
||||
int? targetWidth,
|
||||
int? targetHeight,
|
||||
bool allowUpscaling = true,
|
||||
}) async {
|
||||
if (targetWidth != null) {
|
||||
assert(allowUpscaling || targetWidth <= width);
|
||||
if (!(allowUpscaling || targetWidth <= width)) {
|
||||
print("not allow upscaling but targetWidth > width");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (targetHeight != null) {
|
||||
assert(allowUpscaling || targetHeight <= height);
|
||||
if (!(allowUpscaling || targetHeight <= height)) {
|
||||
print("not allow upscaling but targetHeight > height");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final ui.ImmutableBuffer buffer;
|
||||
try {
|
||||
buffer = await ui.ImmutableBuffer.fromUint8List(pixels);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final ui.ImageDescriptor descriptor;
|
||||
try {
|
||||
descriptor = ui.ImageDescriptor.raw(
|
||||
buffer,
|
||||
width: width,
|
||||
height: height,
|
||||
rowBytes: rowBytes,
|
||||
pixelFormat: format,
|
||||
);
|
||||
if (!allowUpscaling) {
|
||||
if (targetWidth != null && targetWidth > descriptor.width) {
|
||||
targetWidth = descriptor.width;
|
||||
}
|
||||
if (targetHeight != null && targetHeight > descriptor.height) {
|
||||
targetHeight = descriptor.height;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print("ImageDescriptor.raw failed: $e");
|
||||
buffer.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final ui.Codec codec;
|
||||
try {
|
||||
codec = await descriptor.instantiateCodec(
|
||||
targetWidth: targetWidth,
|
||||
targetHeight: targetHeight,
|
||||
);
|
||||
} catch (e) {
|
||||
print("instantiateCodec failed: $e");
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
final ui.FrameInfo frameInfo;
|
||||
try {
|
||||
frameInfo = await codec.getNextFrame();
|
||||
} catch (e) {
|
||||
print("getNextFrame failed: $e");
|
||||
codec.dispose();
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
codec.dispose();
|
||||
buffer.dispose();
|
||||
descriptor.dispose();
|
||||
return frameInfo.image;
|
||||
}
|
||||
|
||||
class ImagePainter extends CustomPainter {
|
||||
ImagePainter({
|
||||
required this.image,
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.scale,
|
||||
});
|
||||
|
||||
ui.Image? image;
|
||||
double x;
|
||||
double y;
|
||||
double scale;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (image == null) return;
|
||||
if (x.isNaN || y.isNaN) return;
|
||||
canvas.scale(scale, scale);
|
||||
// https://github.com/flutter/flutter/issues/76187#issuecomment-784628161
|
||||
// https://api.flutter-io.cn/flutter/dart-ui/FilterQuality.html
|
||||
var paint = Paint();
|
||||
if ((scale - 1.0).abs() > 0.001) {
|
||||
paint.filterQuality = FilterQuality.medium;
|
||||
if (scale > 10.00000) {
|
||||
paint.filterQuality = FilterQuality.high;
|
||||
}
|
||||
}
|
||||
// It's strange that if (scale < 0.5 && paint.filterQuality == FilterQuality.medium)
|
||||
// The canvas.drawImage will not work on web
|
||||
if (isWeb) {
|
||||
paint.filterQuality = FilterQuality.high;
|
||||
}
|
||||
canvas.drawImage(
|
||||
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) {
|
||||
return oldDelegate != this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
|
||||
/// must keep the order
|
||||
// ignore: constant_identifier_names
|
||||
enum WindowType {
|
||||
Main,
|
||||
RemoteDesktop,
|
||||
FileTransfer,
|
||||
ViewCamera,
|
||||
PortForward,
|
||||
Terminal,
|
||||
Unknown
|
||||
}
|
||||
|
||||
extension Index on int {
|
||||
WindowType get windowType {
|
||||
switch (this) {
|
||||
case 0:
|
||||
return WindowType.Main;
|
||||
case 1:
|
||||
return WindowType.RemoteDesktop;
|
||||
case 2:
|
||||
return WindowType.FileTransfer;
|
||||
case 3:
|
||||
return WindowType.ViewCamera;
|
||||
case 4:
|
||||
return WindowType.PortForward;
|
||||
case 5:
|
||||
return WindowType.Terminal;
|
||||
default:
|
||||
return WindowType.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MultiWindowCallResult {
|
||||
int windowId;
|
||||
dynamic result;
|
||||
|
||||
MultiWindowCallResult(this.windowId, this.result);
|
||||
}
|
||||
|
||||
/// Window Manager
|
||||
/// mainly use it in `Main Window`
|
||||
/// use it in sub window is not recommended
|
||||
class RustDeskMultiWindowManager {
|
||||
RustDeskMultiWindowManager._();
|
||||
|
||||
static final instance = RustDeskMultiWindowManager._();
|
||||
|
||||
final Set<int> _inactiveWindows = {};
|
||||
final Set<int> _activeWindows = {};
|
||||
final List<AsyncCallback> _windowActiveCallbacks = List.empty(growable: true);
|
||||
final List<int> _remoteDesktopWindows = List.empty(growable: true);
|
||||
final List<int> _fileTransferWindows = List.empty(growable: true);
|
||||
final List<int> _viewCameraWindows = List.empty(growable: true);
|
||||
final List<int> _portForwardWindows = List.empty(growable: true);
|
||||
final List<int> _terminalWindows = List.empty(growable: true);
|
||||
|
||||
moveTabToNewWindow(int windowId, String peerId, String sessionId,
|
||||
WindowType windowType) async {
|
||||
var params = {
|
||||
'type': windowType.index,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'session_id': sessionId,
|
||||
};
|
||||
if (windowType == WindowType.RemoteDesktop) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
_remoteDesktopWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
} else if (windowType == WindowType.ViewCamera) {
|
||||
await _newSession(
|
||||
false,
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
peerId,
|
||||
_viewCameraWindows,
|
||||
jsonEncode(params),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// This function must be called in the main window thread.
|
||||
// Because the _remoteDesktopWindows is managed in that thread.
|
||||
openMonitorSession(int windowId, String peerId, int display, int displayCount,
|
||||
Rect? screenRect, int windowType) async {
|
||||
final isCamera = windowType == WindowType.ViewCamera.index;
|
||||
final windowIDs = isCamera ? _viewCameraWindows : _remoteDesktopWindows;
|
||||
if (windowIDs.length > 1) {
|
||||
for (final windowId in windowIDs) {
|
||||
if (await DesktopMultiWindow.invokeMethod(
|
||||
windowId,
|
||||
kWindowEventActiveDisplaySession,
|
||||
jsonEncode({
|
||||
'id': peerId,
|
||||
'display': display,
|
||||
}))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final displays = display == kAllDisplayValue
|
||||
? List.generate(displayCount, (index) => index)
|
||||
: [display];
|
||||
var params = {
|
||||
'type': windowType,
|
||||
'id': peerId,
|
||||
'tab_window_id': windowId,
|
||||
'display': display,
|
||||
'displays': displays,
|
||||
};
|
||||
if (screenRect != null) {
|
||||
params['screen_rect'] = {
|
||||
'l': screenRect.left,
|
||||
't': screenRect.top,
|
||||
'r': screenRect.right,
|
||||
'b': screenRect.bottom,
|
||||
};
|
||||
}
|
||||
await _newSession(
|
||||
false,
|
||||
windowType.windowType,
|
||||
isCamera ? kWindowEventNewViewCamera : kWindowEventNewRemoteDesktop,
|
||||
peerId,
|
||||
windowIDs,
|
||||
jsonEncode(params),
|
||||
screenRect: screenRect,
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> newSessionWindow(
|
||||
WindowType type,
|
||||
String remoteId,
|
||||
String msg,
|
||||
List<int> windows,
|
||||
bool withScreenRect,
|
||||
) async {
|
||||
final windowController = await DesktopMultiWindow.createWindow(msg);
|
||||
if (isWindows) {
|
||||
windowController.setInitBackgroundColor(Colors.black);
|
||||
}
|
||||
final windowId = windowController.windowId;
|
||||
if (!withScreenRect) {
|
||||
windowController
|
||||
..setFrame(const Offset(0, 0) &
|
||||
Size(1280 + windowId * 20, 720 + windowId * 20))
|
||||
..center()
|
||||
..setTitle(getWindowNameWithId(
|
||||
remoteId,
|
||||
overrideType: type,
|
||||
));
|
||||
} else {
|
||||
windowController.setTitle(getWindowNameWithId(
|
||||
remoteId,
|
||||
overrideType: type,
|
||||
));
|
||||
}
|
||||
if (isMacOS) {
|
||||
Future.microtask(() => windowController.show());
|
||||
}
|
||||
registerActiveWindow(windowId);
|
||||
windows.add(windowId);
|
||||
return windowId;
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> _newSession(
|
||||
bool openInTabs,
|
||||
WindowType type,
|
||||
String methodName,
|
||||
String remoteId,
|
||||
List<int> windows,
|
||||
String msg, {
|
||||
Rect? screenRect,
|
||||
}) async {
|
||||
if (openInTabs) {
|
||||
if (windows.isEmpty) {
|
||||
final windowId = await newSessionWindow(
|
||||
type, remoteId, msg, windows, screenRect != null);
|
||||
return MultiWindowCallResult(windowId, null);
|
||||
} else {
|
||||
return call(type, methodName, msg);
|
||||
}
|
||||
} else {
|
||||
if (_inactiveWindows.isNotEmpty) {
|
||||
for (final windowId in windows) {
|
||||
if (_inactiveWindows.contains(windowId)) {
|
||||
if (screenRect == null) {
|
||||
await restoreWindowPosition(type,
|
||||
windowId: windowId, peerId: remoteId);
|
||||
}
|
||||
await DesktopMultiWindow.invokeMethod(windowId, methodName, msg);
|
||||
if (methodName != kWindowEventNewRemoteDesktop) {
|
||||
WindowController.fromWindowId(windowId).show();
|
||||
}
|
||||
registerActiveWindow(windowId);
|
||||
return MultiWindowCallResult(windowId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
final windowId = await newSessionWindow(
|
||||
type, remoteId, msg, windows, screenRect != null);
|
||||
return MultiWindowCallResult(windowId, null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newSession(
|
||||
WindowType type,
|
||||
String methodName,
|
||||
String remoteId,
|
||||
List<int> windows, {
|
||||
String? password,
|
||||
bool? forceRelay,
|
||||
String? switchUuid,
|
||||
bool? isRDP,
|
||||
bool? isSharedPassword,
|
||||
String? connToken,
|
||||
}) async {
|
||||
var params = {
|
||||
"type": type.index,
|
||||
"id": remoteId,
|
||||
"password": password,
|
||||
"forceRelay": forceRelay
|
||||
};
|
||||
if (switchUuid != null) {
|
||||
params['switch_uuid'] = switchUuid;
|
||||
}
|
||||
if (isRDP != null) {
|
||||
params['isRDP'] = isRDP;
|
||||
}
|
||||
if (isSharedPassword != null) {
|
||||
params['isSharedPassword'] = isSharedPassword;
|
||||
}
|
||||
if (connToken != null) {
|
||||
params['connToken'] = connToken;
|
||||
}
|
||||
final msg = jsonEncode(params);
|
||||
|
||||
// separate window for file transfer is not supported
|
||||
bool openInTabs = type != WindowType.RemoteDesktop ||
|
||||
mainGetLocalBoolOptionSync(kOptionOpenNewConnInTabs);
|
||||
|
||||
if (windows.length > 1 || !openInTabs) {
|
||||
for (final windowId in windows) {
|
||||
if (await DesktopMultiWindow.invokeMethod(
|
||||
windowId, kWindowEventActiveSession, remoteId)) {
|
||||
return MultiWindowCallResult(windowId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _newSession(openInTabs, type, methodName, remoteId, windows, msg);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newRemoteDesktop(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
String? switchUuid,
|
||||
bool? forceRelay,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.RemoteDesktop,
|
||||
kWindowEventNewRemoteDesktop,
|
||||
remoteId,
|
||||
_remoteDesktopWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
switchUuid: switchUuid,
|
||||
isSharedPassword: isSharedPassword,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newFileTransfer(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.FileTransfer,
|
||||
kWindowEventNewFileTransfer,
|
||||
remoteId,
|
||||
_fileTransferWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newViewCamera(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
String? switchUuid,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.ViewCamera,
|
||||
kWindowEventNewViewCamera,
|
||||
remoteId,
|
||||
_viewCameraWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
switchUuid: switchUuid,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newPortForward(
|
||||
String remoteId,
|
||||
bool isRDP, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
return await newSession(
|
||||
WindowType.PortForward,
|
||||
kWindowEventNewPortForward,
|
||||
remoteId,
|
||||
_portForwardWindows,
|
||||
password: password,
|
||||
forceRelay: forceRelay,
|
||||
isRDP: isRDP,
|
||||
isSharedPassword: isSharedPassword,
|
||||
connToken: connToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> newTerminal(
|
||||
String remoteId, {
|
||||
String? password,
|
||||
bool? isSharedPassword,
|
||||
bool? forceRelay,
|
||||
String? connToken,
|
||||
}) async {
|
||||
// Iterate through terminal windows in reverse order to prioritize
|
||||
// the most recently added or used windows, as they are more likely
|
||||
// to have an active session.
|
||||
for (final windowId in _terminalWindows.reversed) {
|
||||
if (await DesktopMultiWindow.invokeMethod(
|
||||
windowId, kWindowEventActiveSession, remoteId)) {
|
||||
return MultiWindowCallResult(windowId, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal windows should always create new windows, not reuse
|
||||
// This avoids the MissingPluginException when trying to invoke
|
||||
// new_terminal on an inactive window
|
||||
var params = {
|
||||
"type": WindowType.Terminal.index,
|
||||
"id": remoteId,
|
||||
"password": password,
|
||||
"forceRelay": forceRelay,
|
||||
"isSharedPassword": isSharedPassword,
|
||||
"connToken": connToken,
|
||||
};
|
||||
final msg = jsonEncode(params);
|
||||
|
||||
// Always create a new window for terminal
|
||||
final windowId = await newSessionWindow(
|
||||
WindowType.Terminal, remoteId, msg, _terminalWindows, false);
|
||||
return MultiWindowCallResult(windowId, null);
|
||||
}
|
||||
|
||||
Future<MultiWindowCallResult> call(
|
||||
WindowType type, String methodName, dynamic args) async {
|
||||
final wnds = _findWindowsByType(type);
|
||||
if (wnds.isEmpty) {
|
||||
return MultiWindowCallResult(kInvalidWindowId, null);
|
||||
}
|
||||
for (final windowId in wnds) {
|
||||
if (_activeWindows.contains(windowId)) {
|
||||
final res =
|
||||
await DesktopMultiWindow.invokeMethod(windowId, methodName, args);
|
||||
return MultiWindowCallResult(windowId, res);
|
||||
}
|
||||
}
|
||||
final res =
|
||||
await DesktopMultiWindow.invokeMethod(wnds[0], methodName, args);
|
||||
return MultiWindowCallResult(wnds[0], res);
|
||||
}
|
||||
|
||||
List<int> _findWindowsByType(WindowType type) {
|
||||
switch (type) {
|
||||
case WindowType.Main:
|
||||
return [kMainWindowId];
|
||||
case WindowType.RemoteDesktop:
|
||||
return _remoteDesktopWindows;
|
||||
case WindowType.FileTransfer:
|
||||
return _fileTransferWindows;
|
||||
case WindowType.ViewCamera:
|
||||
return _viewCameraWindows;
|
||||
case WindowType.PortForward:
|
||||
return _portForwardWindows;
|
||||
case WindowType.Terminal:
|
||||
return _terminalWindows;
|
||||
case WindowType.Unknown:
|
||||
break;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
void clearWindowType(WindowType type) {
|
||||
switch (type) {
|
||||
case WindowType.Main:
|
||||
return;
|
||||
case WindowType.RemoteDesktop:
|
||||
_remoteDesktopWindows.clear();
|
||||
break;
|
||||
case WindowType.FileTransfer:
|
||||
_fileTransferWindows.clear();
|
||||
break;
|
||||
case WindowType.ViewCamera:
|
||||
_viewCameraWindows.clear();
|
||||
break;
|
||||
case WindowType.PortForward:
|
||||
_portForwardWindows.clear();
|
||||
break;
|
||||
case WindowType.Terminal:
|
||||
_terminalWindows.clear();
|
||||
case WindowType.Unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void setMethodHandler(
|
||||
Future<dynamic> Function(MethodCall call, int fromWindowId)? handler) {
|
||||
DesktopMultiWindow.setMethodHandler(handler);
|
||||
}
|
||||
|
||||
Future<void> closeAllSubWindows() async {
|
||||
await Future.wait(WindowType.values.map((e) => _closeWindows(e)));
|
||||
}
|
||||
|
||||
Future<void> _closeWindows(WindowType type) async {
|
||||
if (type == WindowType.Main) {
|
||||
// skip main window, use window manager instead
|
||||
return;
|
||||
}
|
||||
|
||||
List<int> windows = [];
|
||||
try {
|
||||
windows = _findWindowsByType(type);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to getAllSubWindowIds of $type, $e');
|
||||
return;
|
||||
}
|
||||
|
||||
if (windows.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < windows.length; i++) {
|
||||
final wId = windows[i];
|
||||
final shouldSavePos = type != WindowType.Terminal || i == windows.length - 1;
|
||||
if (shouldSavePos) {
|
||||
debugPrint("closing multi window, type: ${type.toString()} id: $wId");
|
||||
try {
|
||||
await saveWindowPosition(type, windowId: wId);
|
||||
} catch (e) {
|
||||
debugPrint('Failed to save window position of $wId, $e');
|
||||
}
|
||||
}
|
||||
try {
|
||||
await WindowController.fromWindowId(wId).setPreventClose(false);
|
||||
await WindowController.fromWindowId(wId).close();
|
||||
_activeWindows.remove(wId);
|
||||
} catch (e) {
|
||||
debugPrint("$e");
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearWindowType(type);
|
||||
}
|
||||
|
||||
Future<List<int>> getAllSubWindowIds() async {
|
||||
try {
|
||||
final windows = await DesktopMultiWindow.getAllSubWindowIds();
|
||||
return windows;
|
||||
} catch (err) {
|
||||
if (err is AssertionError) {
|
||||
return [];
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Set<int> getActiveWindows() {
|
||||
return _activeWindows;
|
||||
}
|
||||
|
||||
Future<void> _notifyActiveWindow() async {
|
||||
for (final callback in _windowActiveCallbacks) {
|
||||
await callback.call();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerActiveWindow(int windowId) async {
|
||||
_activeWindows.add(windowId);
|
||||
_inactiveWindows.remove(windowId);
|
||||
await _notifyActiveWindow();
|
||||
}
|
||||
|
||||
/// Remove active window which has [`windowId`]
|
||||
///
|
||||
/// [Availability]
|
||||
/// This function should only be called from main window.
|
||||
/// For other windows, please post a unregister(hide) event to main window handler:
|
||||
/// `rustDeskWinManager.call(WindowType.Main, kWindowEventHide, {"id": windowId!});`
|
||||
Future<void> unregisterActiveWindow(int windowId) async {
|
||||
_activeWindows.remove(windowId);
|
||||
if (windowId != kMainWindowId) {
|
||||
_inactiveWindows.add(windowId);
|
||||
}
|
||||
await _notifyActiveWindow();
|
||||
}
|
||||
|
||||
void registerActiveWindowListener(AsyncCallback callback) {
|
||||
_windowActiveCallbacks.add(callback);
|
||||
}
|
||||
|
||||
void unregisterActiveWindowListener(AsyncCallback callback) {
|
||||
_windowActiveCallbacks.remove(callback);
|
||||
}
|
||||
|
||||
// This function is called from the main window.
|
||||
// It will query the active remote windows to get their coords.
|
||||
Future<List<String>> getOtherRemoteWindowCoords(int wId) async {
|
||||
List<String> coords = [];
|
||||
for (final windowId in _remoteDesktopWindows) {
|
||||
if (windowId != wId) {
|
||||
if (_activeWindows.contains(windowId)) {
|
||||
final res = await DesktopMultiWindow.invokeMethod(
|
||||
windowId, kWindowEventRemoteWindowCoords, '');
|
||||
if (res != null) {
|
||||
coords.add(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
|
||||
// This function is called from one remote window.
|
||||
// Only the main window knows `_remoteDesktopWindows` and `_activeWindows`.
|
||||
// So we need to call the main window to get the other remote windows' coords.
|
||||
Future<List<RemoteWindowCoords>> getOtherRemoteWindowCoordsFromMain() async {
|
||||
List<RemoteWindowCoords> coords = [];
|
||||
// Call the main window to get the coords of other remote windows.
|
||||
String res = await DesktopMultiWindow.invokeMethod(
|
||||
kMainWindowId, kWindowEventRemoteWindowCoords, kWindowId.toString());
|
||||
List<dynamic> list = jsonDecode(res);
|
||||
for (var item in list) {
|
||||
coords.add(RemoteWindowCoords.fromJson(jsonDecode(item)));
|
||||
}
|
||||
return coords;
|
||||
}
|
||||
}
|
||||
|
||||
final rustDeskWinManager = RustDeskMultiWindowManager.instance;
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/main.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
|
||||
enum SystemWindowTheme { light, dark }
|
||||
|
||||
/// The platform channel for RustDesk.
|
||||
class RdPlatformChannel {
|
||||
RdPlatformChannel._();
|
||||
|
||||
static final RdPlatformChannel _windowUtil = RdPlatformChannel._();
|
||||
|
||||
static RdPlatformChannel get instance => _windowUtil;
|
||||
|
||||
final MethodChannel _hostMethodChannel =
|
||||
MethodChannel("org.rustdesk.rustdesk/host");
|
||||
|
||||
/// Bump the position of the mouse cursor, if applicable
|
||||
Future<bool> bumpMouse({required int dx, required int dy}) async {
|
||||
// No debug output; this call is too chatty.
|
||||
|
||||
bool? result = await _hostMethodChannel
|
||||
.invokeMethod("bumpMouse", {"dx": dx, "dy": dy});
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Change the theme of the system window
|
||||
Future<void> changeSystemWindowTheme(SystemWindowTheme theme) {
|
||||
assert(isMacOS);
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
"[Window ${kWindowId ?? 'Main'}] change system window theme to ${theme.name}");
|
||||
}
|
||||
return _hostMethodChannel
|
||||
.invokeMethod("setWindowTheme", {"themeName": theme.name});
|
||||
}
|
||||
|
||||
/// Terminate .app manually.
|
||||
Future<void> terminate() {
|
||||
assert(isMacOS);
|
||||
return _hostMethodChannel.invokeMethod("terminate");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/// A small helper for accumulating fractional mouse deltas and emitting integer deltas.
|
||||
///
|
||||
/// Relative mouse mode uses integer deltas on the wire, but Flutter pointer deltas
|
||||
/// are doubles. This accumulator preserves sub-pixel movement by carrying the
|
||||
/// fractional remainder across events.
|
||||
class RelativeMouseDelta {
|
||||
final int x;
|
||||
final int y;
|
||||
|
||||
const RelativeMouseDelta(this.x, this.y);
|
||||
}
|
||||
|
||||
/// Accumulates fractional mouse deltas and returns integer deltas when available.
|
||||
class RelativeMouseAccumulator {
|
||||
double _fracX = 0.0;
|
||||
double _fracY = 0.0;
|
||||
|
||||
/// Adds a delta and returns an integer delta when at least one axis reaches a
|
||||
/// magnitude of 1px (after truncation towards zero).
|
||||
///
|
||||
/// If [maxDelta] is > 0, the returned integer delta is clamped to
|
||||
/// [-maxDelta, maxDelta] on each axis.
|
||||
RelativeMouseDelta? add(
|
||||
double dx,
|
||||
double dy, {
|
||||
required int maxDelta,
|
||||
}) {
|
||||
// Guard against misuse: negative maxDelta would silently disable clamping.
|
||||
assert(maxDelta >= 0, 'maxDelta must be non-negative');
|
||||
|
||||
_fracX += dx;
|
||||
_fracY += dy;
|
||||
|
||||
int intX = _fracX.truncate();
|
||||
int intY = _fracY.truncate();
|
||||
|
||||
if (intX == 0 && intY == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clamp before subtracting so excess movement is preserved in the accumulator
|
||||
// rather than being permanently discarded during spikes.
|
||||
if (maxDelta > 0) {
|
||||
intX = intX.clamp(-maxDelta, maxDelta);
|
||||
intY = intY.clamp(-maxDelta, maxDelta);
|
||||
}
|
||||
|
||||
_fracX -= intX;
|
||||
_fracY -= intY;
|
||||
|
||||
return RelativeMouseDelta(intX, intY);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_fracX = 0.0;
|
||||
_fracY = 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Clamp custom scale percent to supported bounds.
|
||||
/// Keep this in sync with the slider's minimum in the desktop toolbar UI.
|
||||
///
|
||||
/// This function exists to ensure consistent clamping behavior across the app
|
||||
/// and to provide a single point of reference for the valid scale range.
|
||||
int clampCustomScalePercent(int percent) {
|
||||
return percent.clamp(kScaleCustomMinPercent, kScaleCustomMaxPercent);
|
||||
}
|
||||
|
||||
/// Parse a string percent and clamp. Defaults to 100 when invalid.
|
||||
int parseCustomScalePercent(String? s, {int defaultPercent = 100}) {
|
||||
final parsed = int.tryParse(s ?? '') ?? defaultPercent;
|
||||
return clampCustomScalePercent(parsed);
|
||||
}
|
||||
|
||||
/// Convert a percent value to scale factor after clamping.
|
||||
double percentToScale(int percent) => clampCustomScalePercent(percent) / 100.0;
|
||||
|
||||
/// Fetch, parse and clamp the custom scale percent for a session.
|
||||
Future<int> getSessionCustomScalePercent(UuidValue sessionId) async {
|
||||
final opt = await bind.sessionGetFlutterOption(
|
||||
sessionId: sessionId, k: kCustomScalePercentKey);
|
||||
return parseCustomScalePercent(opt);
|
||||
}
|
||||
|
||||
/// Fetch and compute the custom scale factor for a session.
|
||||
Future<double> getSessionCustomScale(UuidValue sessionId) async {
|
||||
final p = await getSessionCustomScalePercent(sessionId);
|
||||
return percentToScale(p);
|
||||
}
|
||||
Reference in New Issue
Block a user