mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-05 15:41:23 +03:00
* fix: android: replace all-files access with scoped storage + system picker Remove MANAGE_EXTERNAL_STORAGE, READ_EXTERNAL_STORAGE, and WRITE_EXTERNAL_STORAGE from the Android manifest. Remove requestLegacyExternalStorage. Replace broad external storage with app-scoped external storage for the file-transfer workspace. File import uses the system file_picker. File export uses Android's SAF ACTION_CREATE_DOCUMENT with path validation that restricts export sources to app-owned directories. Remove the external_path dependency. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: refine file import feedback Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: use SAF for file imports Replace file_picker imports with Android's Storage Access Framework to avoid legacy storage permissions, stale cached files, and duplicate staging of large imports. Stream selected documents into app-scoped storage with failure-safe replacement, keep exports restricted to validated app storage roots, use filesDir for the internal fallback workspace, and remove legacy permissions contributed during manifest merging. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: keep file imports in the selected directory Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: reset projection and constrain file workspace Release capture resources when media projection is revoked externally. Keep Android local file navigation within the app-scoped workspace. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: handle scoped storage start-up regressions. Allow zero digits in POSIX filenames by rejecting NUL explicitly, and initialise the app-specific home directory before the Android service starts the native server. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: update content resolver mode to use 'wt' instead of 'w' to prevent trailing bytes from old document whilst reporting sucess Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android, enforce file workspace boundary on the server, and unblock the ui thread. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: android: validate rename destinations against the app workspace bound file-operation paths. report rename failures, general import failures, and unregister / reregister projection when its onStop callback fires. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: reconnect was refreshing the directory with net entry instances, while selected items retained the old instances, it was reporting a selected item, but checkbox statue used object identity, and appeared unchecked. Fixed by reconciling by path and entry type before replacing the directory snapshot, rebinding valid selections, and dropping missing ones. Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix: (android) add SAF folder import and multi item export - import directories using ACTION_OPEN_DOCUMENT_TREE. Export multiple files, logs, and screen recordings via export buttons, add localisation keys for new actions Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> * fix(android): harden scoped storage file handling - create new SAF documents instead of overwriting export sources - reject empty peer paths except for home directory reads - report directory backup restore and cleanup failures - resolve log export paths from the configured app name Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): harden scoped-storage file operations - snapshot directory exports before writing to the destination - query document provider metadata off the main thread - reject invalid remote directories without read timeouts Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle SAF directory name collisions - reject dot-segment folder names during import - fail imports with duplicate document display names - only reuse matching directories during export Signed-off-by: fufesou <linlong1266@gmail.com> * fix(android): handle SAF folder import collisions Reject filesystem-equivalent destination names and avoid showing a failure when folder overwrite is skipped. Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: michaeljclarkk <104532890+michaeljclarkk@users.noreply.github.com> Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: fufesou <linlong1266@gmail.com>
322 lines
11 KiB
Dart
322 lines
11 KiB
Dart
import 'dart:convert';
|
|
import 'dart:ffi';
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:ffi/ffi.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hbb/consts.dart';
|
|
import 'package:flutter_hbb/main.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../common.dart';
|
|
import '../generated_bridge.dart';
|
|
|
|
final class RgbaFrame extends Struct {
|
|
@Uint32()
|
|
external int len;
|
|
external Pointer<Uint8> data;
|
|
}
|
|
|
|
typedef F3 = Pointer<Uint8> Function(Pointer<Utf8>, int);
|
|
typedef F3Dart = Pointer<Uint8> Function(Pointer<Utf8>, Int32);
|
|
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
|
|
|
/// The Linux bundle keeps the core library at lib/librustdesk.so next to the
|
|
/// executable. Prefer that copy, mirroring flutter/linux/main.cc: the plain
|
|
/// name relies on the loader search path, which repackaged installs may not
|
|
/// cover. https://github.com/rustdesk/rustdesk/discussions/14407
|
|
DynamicLibrary _openLinuxCoreLib() {
|
|
final bundled =
|
|
'${File(Platform.resolvedExecutable).parent.path}/lib/librustdesk.so';
|
|
try {
|
|
if (File(bundled).existsSync()) {
|
|
return DynamicLibrary.open(bundled);
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Failed to load '$bundled': $e");
|
|
}
|
|
return DynamicLibrary.open('librustdesk.so');
|
|
}
|
|
|
|
/// FFI wrapper around the native Rust core.
|
|
/// Hides the platform differences.
|
|
class PlatformFFI {
|
|
String _dir = '';
|
|
// _homeDir is only needed for Android and IOS.
|
|
String _homeDir = '';
|
|
final _eventHandlers = <String, Map<String, HandleEvent>>{};
|
|
late RustdeskImpl _ffiBind;
|
|
late String _appType;
|
|
StreamEventHandler? _eventCallback;
|
|
|
|
PlatformFFI._();
|
|
|
|
static final PlatformFFI instance = PlatformFFI._();
|
|
final _toAndroidChannel = const MethodChannel('mChannel');
|
|
|
|
RustdeskImpl get ffiBind => _ffiBind;
|
|
F3? _session_get_rgba;
|
|
|
|
static get localeName => Platform.localeName;
|
|
|
|
static get isMain => instance._appType == kAppTypeMain;
|
|
|
|
static String getByName(String name, [String arg = '']) {
|
|
return '';
|
|
}
|
|
|
|
static void setByName(String name, [String value = '']) {}
|
|
|
|
static Future<String> getVersion() async {
|
|
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
|
return packageInfo.version;
|
|
}
|
|
|
|
bool registerEventHandler(
|
|
String eventName, String handlerName, HandleEvent handler, {bool replace = false}) {
|
|
debugPrint('registerEventHandler $eventName $handlerName');
|
|
var handlers = _eventHandlers[eventName];
|
|
if (handlers == null) {
|
|
_eventHandlers[eventName] = {handlerName: handler};
|
|
return true;
|
|
} else {
|
|
if (!replace && handlers.containsKey(handlerName)) {
|
|
return false;
|
|
} else {
|
|
handlers[handlerName] = handler;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
void unregisterEventHandler(String eventName, String handlerName) {
|
|
debugPrint('unregisterEventHandler $eventName $handlerName');
|
|
var handlers = _eventHandlers[eventName];
|
|
if (handlers != null) {
|
|
handlers.remove(handlerName);
|
|
}
|
|
}
|
|
|
|
String translate(String name, String locale) =>
|
|
_ffiBind.translate(name: name, locale: locale);
|
|
|
|
Uint8List? getRgba(SessionID sessionId, int display, int bufSize) {
|
|
if (_session_get_rgba == null) return null;
|
|
final sessionIdStr = sessionId.toString();
|
|
var a = sessionIdStr.toNativeUtf8();
|
|
try {
|
|
final buffer = _session_get_rgba!(a, display);
|
|
if (buffer == nullptr) {
|
|
return null;
|
|
}
|
|
final data = buffer.asTypedList(bufSize);
|
|
return data;
|
|
} finally {
|
|
malloc.free(a);
|
|
}
|
|
}
|
|
|
|
int getRgbaSize(SessionID sessionId, int display) =>
|
|
_ffiBind.sessionGetRgbaSize(sessionId: sessionId, display: display);
|
|
void nextRgba(SessionID sessionId, int display) =>
|
|
_ffiBind.sessionNextRgba(sessionId: sessionId, display: display);
|
|
void registerPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
|
|
_ffiBind.sessionRegisterPixelbufferTexture(
|
|
sessionId: sessionId, display: display, ptr: ptr);
|
|
void registerGpuTexture(SessionID sessionId, int display, int ptr) =>
|
|
_ffiBind.sessionRegisterGpuTexture(
|
|
sessionId: sessionId, display: display, ptr: ptr);
|
|
|
|
/// Init the FFI class, loads the native Rust core library.
|
|
Future<void> init(String appType) async {
|
|
_appType = appType;
|
|
final dylib = isAndroid
|
|
? DynamicLibrary.open('librustdesk.so')
|
|
: isLinux
|
|
? _openLinuxCoreLib()
|
|
: isWindows
|
|
? DynamicLibrary.open('librustdesk.dll')
|
|
:
|
|
// Use executable itself as the dynamic library for MacOS.
|
|
// Multiple dylib instances will cause some global instances to be invalid.
|
|
// eg. `lazy_static` objects in rust side, will be created more than once, which is not expected.
|
|
//
|
|
// isMacOS? DynamicLibrary.open("liblibrustdesk.dylib") :
|
|
DynamicLibrary.process();
|
|
debugPrint('initializing FFI $_appType');
|
|
try {
|
|
_session_get_rgba = dylib.lookupFunction<F3Dart, F3>("session_get_rgba");
|
|
try {
|
|
// SYSTEM user failed
|
|
_dir = (await getApplicationDocumentsDirectory()).path;
|
|
} catch (e) {
|
|
debugPrint('Failed to get documents directory: $e');
|
|
}
|
|
_ffiBind = RustdeskImpl(dylib);
|
|
|
|
if (isLinux) {
|
|
if (isMain) {
|
|
// Start a dbus service for uri links, no need to await
|
|
_ffiBind.mainStartDbusServer();
|
|
}
|
|
} else if (isMacOS && isMain) {
|
|
// Start ipc service for uri links.
|
|
_ffiBind.mainStartIpcUrlServer();
|
|
}
|
|
_startListenEvent(_ffiBind); // global event
|
|
try {
|
|
if (isAndroid) {
|
|
// Android file transfer uses app-specific storage. User-selected
|
|
// files enter and leave this workspace through the system picker.
|
|
_homeDir = (await getExternalStorageDirectory())?.path ??
|
|
(await getApplicationSupportDirectory()).path;
|
|
} else if (isIOS) {
|
|
// The previous code was `_homeDir = (await getDownloadsDirectory())?.path ?? '';`,
|
|
// which provided the `downloads` path in the sandbox.
|
|
// It is unclear why we now use the `data` directory in the sandbox instead.
|
|
_homeDir = _ffiBind.mainGetDataDirIos(appDir: _dir);
|
|
} else {
|
|
// no need to set home dir
|
|
}
|
|
} catch (e) {
|
|
debugPrintStack(label: 'initialize failed: $e');
|
|
}
|
|
String id = 'NA';
|
|
String name = 'Flutter';
|
|
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
|
if (isAndroid) {
|
|
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
|
name = '${androidInfo.brand}-${androidInfo.model}';
|
|
id = androidInfo.id.hashCode.toString();
|
|
androidVersion = androidInfo.version.sdkInt;
|
|
} else if (isIOS) {
|
|
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
|
|
name = iosInfo.utsname.machine;
|
|
id = iosInfo.identifierForVendor.hashCode.toString();
|
|
} else if (isLinux) {
|
|
LinuxDeviceInfo linuxInfo = await deviceInfo.linuxInfo;
|
|
name = linuxInfo.name;
|
|
id = linuxInfo.machineId ?? linuxInfo.id;
|
|
} else if (isWindows) {
|
|
try {
|
|
// request windows build number to fix overflow on win7
|
|
windowsBuildNumber = getWindowsTargetBuildNumber();
|
|
WindowsDeviceInfo winInfo = await deviceInfo.windowsInfo;
|
|
name = winInfo.computerName;
|
|
id = winInfo.computerName;
|
|
} catch (e) {
|
|
debugPrintStack(label: "get windows device info failed: $e");
|
|
name = "unknown";
|
|
id = "unknown";
|
|
}
|
|
} else if (isMacOS) {
|
|
MacOsDeviceInfo macOsInfo = await deviceInfo.macOsInfo;
|
|
name = macOsInfo.computerName;
|
|
id = macOsInfo.systemGUID ?? '';
|
|
}
|
|
if (isAndroid || isIOS) {
|
|
debugPrint(
|
|
'_appType:$_appType,info1-id:$id,info2-name:$name,dir:$_dir,homeDir:$_homeDir');
|
|
} else {
|
|
debugPrint(
|
|
'_appType:$_appType,info1-id:$id,info2-name:$name,dir:$_dir');
|
|
}
|
|
if (desktopType == DesktopType.cm) {
|
|
await _ffiBind.cmInit();
|
|
}
|
|
await _ffiBind.mainDeviceId(id: id);
|
|
await _ffiBind.mainDeviceName(name: name);
|
|
await _ffiBind.mainSetHomeDir(home: _homeDir);
|
|
await _ffiBind.mainInit(
|
|
appDir: _dir,
|
|
customClientConfig: '',
|
|
);
|
|
} catch (e) {
|
|
debugPrintStack(label: 'initialize failed: $e');
|
|
}
|
|
version = await getVersion();
|
|
}
|
|
|
|
Future<bool> tryHandle(Map<String, dynamic> evt) async {
|
|
final name = evt['name'];
|
|
if (name != null) {
|
|
final handlers = _eventHandlers[name];
|
|
if (handlers != null) {
|
|
if (handlers.isNotEmpty) {
|
|
for (var handler in handlers.values) {
|
|
await handler(evt);
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Start listening to the Rust core's events and frames.
|
|
void _startListenEvent(RustdeskImpl rustdeskImpl) {
|
|
final appType =
|
|
_appType == kAppTypeDesktopRemote ? '$_appType,$kWindowId' : _appType;
|
|
var sink = rustdeskImpl.startGlobalEventStream(appType: appType);
|
|
sink.listen((message) {
|
|
() async {
|
|
try {
|
|
Map<String, dynamic> event = json.decode(message);
|
|
// _tryHandle here may be more flexible than _eventCallback
|
|
if (!await tryHandle(event)) {
|
|
if (_eventCallback != null) {
|
|
await _eventCallback!(event);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('json.decode fail(): $e');
|
|
}
|
|
}();
|
|
});
|
|
}
|
|
|
|
void setEventCallback(StreamEventHandler fun) async {
|
|
_eventCallback = fun;
|
|
}
|
|
|
|
void setRgbaCallback(void Function(int, Uint8List) fun) async {}
|
|
|
|
// web only, decoded WebCodecs frames arriving as ready-made images
|
|
void setVideoFrameCallback(
|
|
Future<void> Function(int, ui.Image, bool Function()) fun) {}
|
|
|
|
void clearVideoFrameCallback() {}
|
|
|
|
void startDesktopWebListener() {}
|
|
|
|
void stopDesktopWebListener() {}
|
|
|
|
void setMethodCallHandler(FMethod callback) {
|
|
_toAndroidChannel.setMethodCallHandler((call) async {
|
|
callback(call.method, call.arguments);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
invokeMethod(String method, [dynamic arguments]) async {
|
|
if (!isAndroid) return Future<bool>(() => false);
|
|
return await _toAndroidChannel.invokeMethod(method, arguments);
|
|
}
|
|
|
|
Future<T?> invokeMethodWithResult<T>(String method,
|
|
[dynamic arguments]) async {
|
|
if (!isAndroid) return null;
|
|
return await _toAndroidChannel.invokeMethod<T>(method, arguments);
|
|
}
|
|
|
|
void syncAndroidServiceAppDirConfigPath() {
|
|
invokeMethod(AndroidChannel.kSyncAppDirConfigPath, _dir);
|
|
}
|
|
|
|
void setFullscreenCallback(void Function(bool) fun) {}
|
|
}
|