Files
rustdesk/flutter/lib/models/web_model.dart
rustdeskandClaude Fable 5 7c23e1f4b9 fix: texture render lifetime protocol, watchdog fallback, startup probe
Root cause of #15848 (and the long-standing macOS #6296 / Linux #3343
class): raw native texture pointers are shared across the platform
thread, the engine raster thread and the video thread, with teardown
ordered by a 100 ms sleep - or, when moving a tab to a new window, by
nothing at all. A lost race frees the texture while it is still in use:
the raster thread parks on a destroyed lock (frozen/black view, a
never-presented 'transparent' hole, every later session black) and the
video thread hangs while holding session locks (app half-dead until
restart, still reported as Responding).

- unregister textures with compare-and-clear (new session_unregister_*
  FFI): a late clear can no longer wipe a new window's registration
  (#8016) and Rust never keeps pushing into a freed texture; the 100 ms
  sleeps are gone (the plugins now drain in-flight pushes and defer
  object deletion until the raster thread is done)
- guard the async texture create path against destroy racing it (#13596)
- per-display locks: the per-frame plugin call no longer holds
  session-level locks, so a stalled plugin or driver call cannot freeze
  every window's UI thread
- adopt the frame size after 30 consecutive mismatches instead of
  dropping frames forever (silent black screen on a live connection)
- watchdog: frames pushed but never consumed by the engine fall the
  session back to software rendering live, record texture-render-health,
  and flip the effective default off; toggling the option clears the
  record and re-arms validation
- Dart raster-stall monitor records a hung raster thread for the next
  launch (rendering cannot be rescued in-process in that state)
- startup probe: render one frame through a 1x1 texture in the main
  window each launch; failure disables texture rendering before the
  first session goes black, a pass self-heals a stale failure record

Platform defaults are unchanged (macOS off, Win10+ on, Linux on).
Requires flutter_texture_rgba_renderer ad4c37e and
flutter_gpu_texture_renderer 767bb9f (pinned in pubspec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:54:17 +08:00

269 lines
8.4 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);
void unregisterPixelbufferTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterPixelbufferTexture(
sessionId: sessionId, display: display, ptr: ptr);
void unregisterGpuTexture(SessionID sessionId, int display, int ptr) =>
_ffiBind.sessionUnregisterGpuTexture(
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;
}
// just for compilation
void syncAndroidServiceAppDirConfigPath() {}
void setFullscreenCallback(void Function(bool) fun) {
context["onFullscreenChanged"] = (bool v) {
fun(v);
};
}
}