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>
268 lines
8.1 KiB
Dart
268 lines
8.1 KiB
Dart
// ignore_for_file: avoid_web_libraries_in_flutter
|
|
|
|
import 'dart:convert';
|
|
import 'dart:js_interop';
|
|
import 'dart:js_interop_unsafe';
|
|
import 'dart:typed_data';
|
|
import 'dart:js';
|
|
import 'dart:html';
|
|
import 'dart:async';
|
|
import 'dart:ui' as ui;
|
|
import 'dart:ui_web' as ui_web;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_hbb/common/widgets/login.dart';
|
|
import 'package:flutter_hbb/models/state_model.dart';
|
|
import 'package:flutter_hbb/models/web_video_frame_queue.dart';
|
|
|
|
import 'package:flutter_hbb/web/bridge.dart';
|
|
import 'package:flutter_hbb/common.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
final List<StreamSubscription<MouseEvent>> mouseListeners = [];
|
|
final List<StreamSubscription<KeyboardEvent>> keyListeners = [];
|
|
|
|
// WebCodecs VideoFrames handed over from js/src/webcodecs.js arrive as plain
|
|
// interop objects (the package language version predates extension types).
|
|
// This side owns each frame and must close it quickly: hardware decoders
|
|
// stall once their small output frame pool is exhausted.
|
|
int _videoFrameWidth(JSObject frame) =>
|
|
frame.getProperty<JSNumber>('displayWidth'.toJS).toDartInt;
|
|
int _videoFrameHeight(JSObject frame) =>
|
|
frame.getProperty<JSNumber>('displayHeight'.toJS).toDartInt;
|
|
void _closeVideoFrame(JSObject frame) {
|
|
try {
|
|
frame.callMethod<JSAny?>('close'.toJS);
|
|
} catch (error) {
|
|
debugPrint('VideoFrame.close failed: $error');
|
|
}
|
|
}
|
|
|
|
typedef HandleEvent = Future<void> Function(Map<String, dynamic> evt);
|
|
|
|
class PlatformFFI {
|
|
final _eventHandlers = <String, Map<String, HandleEvent>>{};
|
|
final RustdeskImpl _ffiBind = RustdeskImpl();
|
|
|
|
static String getByName(String name, [String arg = '']) {
|
|
return context.callMethod('getByName', [name, arg]);
|
|
}
|
|
|
|
static void setByName(String name, [String value = '']) {
|
|
context.callMethod('setByName', [name, value]);
|
|
}
|
|
|
|
PlatformFFI._() {
|
|
_videoFrameQueue = WebVideoFrameQueue(
|
|
importFrame: _importVideoFrame,
|
|
closeFrame: _closeVideoFrame,
|
|
disposeImage: (image) => image.dispose(),
|
|
onImportError: _handleVideoFrameImportError,
|
|
onCallbackError: _handleVideoImageCallbackError,
|
|
);
|
|
window.document.addEventListener(
|
|
'visibilitychange',
|
|
(event) => {
|
|
stateGlobal.isWebVisible =
|
|
window.document.visibilityState == 'visible'
|
|
});
|
|
}
|
|
|
|
static final PlatformFFI instance = PlatformFFI._();
|
|
|
|
static get localeName => window.navigator.language;
|
|
RustdeskImpl get ffiBind => _ffiBind;
|
|
|
|
static Future<String> getVersion() async {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
String translate(String name, String locale) =>
|
|
_ffiBind.translate(name: name, locale: locale);
|
|
|
|
Uint8List? getRgba(SessionID sessionId, int display, int bufSize) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
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);
|
|
|
|
Future<void> init(String appType) async {
|
|
Completer completer = Completer();
|
|
context["onInitFinished"] = () {
|
|
completer.complete();
|
|
};
|
|
context['dialog'] = (type, title, text) {
|
|
final uuid = Uuid();
|
|
msgBox(SessionID(uuid.v4()), type, title, text, '', gFFI.dialogManager);
|
|
};
|
|
context['loginDialog'] = () {
|
|
loginDialog();
|
|
};
|
|
context['closeConnection'] = () {
|
|
gFFI.dialogManager.dismissAll();
|
|
closeConnection();
|
|
};
|
|
context.callMethod('init');
|
|
version = getByName('version');
|
|
window.onContextMenu.listen((event) {
|
|
event.preventDefault();
|
|
});
|
|
|
|
context['onRegisteredEvent'] = (String message) {
|
|
try {
|
|
Map<String, dynamic> event = json.decode(message);
|
|
tryHandle(event);
|
|
} catch (e) {
|
|
print('json.decode fail(): $e');
|
|
}
|
|
};
|
|
return completer.future;
|
|
}
|
|
|
|
void setEventCallback(void Function(Map<String, dynamic>) fun) {
|
|
context["onGlobalEvent"] = (String message) {
|
|
try {
|
|
Map<String, dynamic> event = json.decode(message);
|
|
fun(event);
|
|
} catch (e) {
|
|
print('json.decode fail(): $e');
|
|
}
|
|
};
|
|
}
|
|
|
|
void setRgbaCallback(void Function(int, Uint8List) fun) {
|
|
context["onRgba"] = (int display, Uint8List? rgba) {
|
|
if (rgba != null) {
|
|
fun(display, rgba);
|
|
}
|
|
};
|
|
}
|
|
|
|
late final WebVideoFrameQueue<JSObject, ui.Image> _videoFrameQueue;
|
|
|
|
// Zero-readback video path: the JS decoder hands decoded VideoFrames here
|
|
// (checking typeof window.onVideoFrame before every frame), and the engine
|
|
// imports them GPU-to-GPU via createImageBitmap. Unregistering the JS global
|
|
// reverts the JS side to the RGBA readback path.
|
|
void setVideoFrameCallback(
|
|
Future<void> Function(int, ui.Image, bool Function()) fun) {
|
|
_videoFrameQueue.beginSession(fun);
|
|
if (!_videoFrameQueue.isEnabled) return;
|
|
globalContext.setProperty(
|
|
'onVideoFrame'.toJS,
|
|
((JSNumber display, JSObject frame) {
|
|
_videoFrameQueue.submit(display.toDartInt, frame);
|
|
}).toJS,
|
|
);
|
|
}
|
|
|
|
void clearVideoFrameCallback() {
|
|
_videoFrameQueue.endSession();
|
|
globalContext.setProperty('onVideoFrame'.toJS, null);
|
|
}
|
|
|
|
Future<ui.Image> _importVideoFrame(JSObject frame) async {
|
|
return await ui_web.createImageFromTextureSource(frame,
|
|
width: _videoFrameWidth(frame), height: _videoFrameHeight(frame));
|
|
}
|
|
|
|
void _handleVideoFrameImportError(Object error, StackTrace stackTrace) {
|
|
debugPrintStack(
|
|
label: 'createImageFromTextureSource failed, using RGBA path: $error',
|
|
stackTrace: stackTrace);
|
|
globalContext.setProperty('onVideoFrame'.toJS, null);
|
|
}
|
|
|
|
void _handleVideoImageCallbackError(Object error, StackTrace stackTrace) {
|
|
debugPrintStack(
|
|
label: 'video image callback error: $error', stackTrace: stackTrace);
|
|
}
|
|
|
|
void startDesktopWebListener() {
|
|
mouseListeners.add(
|
|
window.document.onContextMenu.listen((evt) => evt.preventDefault()));
|
|
}
|
|
|
|
void stopDesktopWebListener() {
|
|
for (var ml in mouseListeners) {
|
|
ml.cancel();
|
|
}
|
|
mouseListeners.clear();
|
|
for (var kl in keyListeners) {
|
|
kl.cancel();
|
|
}
|
|
keyListeners.clear();
|
|
}
|
|
|
|
void setMethodCallHandler(FMethod callback) {}
|
|
|
|
invokeMethod(String method, [dynamic arguments]) async {
|
|
return true;
|
|
}
|
|
|
|
Future<T?> invokeMethodWithResult<T>(String method,
|
|
[dynamic arguments]) async {
|
|
return null;
|
|
}
|
|
|
|
// just for compilation
|
|
void syncAndroidServiceAppDirConfigPath() {}
|
|
|
|
void setFullscreenCallback(void Function(bool) fun) {
|
|
context["onFullscreenChanged"] = (bool v) {
|
|
fun(v);
|
|
};
|
|
}
|
|
}
|