mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-17 18:01:06 +03:00
Compare commits
93 Commits
webrtc-ipv
...
fix-unzoom
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f487aaed99 | ||
|
|
70e345a76d | ||
|
|
6a62b7bbd9 | ||
|
|
74c818bef9 | ||
|
|
8f499c2eb5 | ||
|
|
2d5d11282b | ||
|
|
f6c89aa06c | ||
|
|
c142cba450 | ||
|
|
ee8c843bc4 | ||
|
|
2cfbbd56b4 | ||
|
|
e7f307bb80 | ||
|
|
a0f24abe80 | ||
|
|
6137be5414 | ||
|
|
e76b7e7bc2 | ||
|
|
1982bb3abd | ||
|
|
8cf138e2cc | ||
|
|
19040f3eef | ||
|
|
c02f17212a | ||
|
|
fbfa37c8fd | ||
|
|
819535f1db | ||
|
|
110ddb9a6c | ||
|
|
9a1c1a1a36 | ||
|
|
3629db2aa0 | ||
|
|
8869a440da | ||
|
|
777407e72e | ||
|
|
20ede1a85f | ||
|
|
a87d9c1082 | ||
|
|
bb06f54070 | ||
|
|
16088cfc80 | ||
|
|
b630f14483 | ||
|
|
d2d7c83602 | ||
|
|
d2561f7f6b | ||
|
|
70c50402a2 | ||
|
|
bbc8ee3bdb | ||
|
|
32e9895294 | ||
|
|
feff308f11 | ||
|
|
5a89aa8c58 | ||
|
|
6e2d838bf1 | ||
|
|
1fabdd6a9c | ||
|
|
afdd1d5c9d | ||
|
|
371e5e8f2d | ||
|
|
4d064a8445 | ||
|
|
02e4f02815 | ||
|
|
bbf86259e9 | ||
|
|
8dfff101f8 | ||
|
|
6934b3cb14 | ||
|
|
8f3beba2d1 | ||
|
|
be1f4e0a33 | ||
|
|
cb068ba5df | ||
|
|
1d98d1a647 | ||
|
|
944f93ba7b | ||
|
|
aea8e2a4a6 | ||
|
|
d65faca21f | ||
|
|
85e7c72ccd | ||
|
|
a3bfe075d5 | ||
|
|
7e2e9b68da | ||
|
|
6871ae130e | ||
|
|
54881092b7 | ||
|
|
34ceb16b63 | ||
|
|
d95c3f2ec9 | ||
|
|
daafa09318 | ||
|
|
96cd5cd1e2 | ||
|
|
d876ea0ce4 | ||
|
|
2a61a0955a | ||
|
|
db7c48e1a7 | ||
|
|
e72e7fb8ba | ||
|
|
4f0f784532 | ||
|
|
844bf40703 | ||
|
|
f8bfc4eb56 | ||
|
|
1061670368 | ||
|
|
8da629c57d | ||
|
|
650a6e21cc | ||
|
|
5f4bb00007 | ||
|
|
a7f1eb4c25 | ||
|
|
be5fb304d4 | ||
|
|
fdd67a875b | ||
|
|
39940b717a | ||
|
|
67b94c7906 | ||
|
|
eeff9eb121 | ||
|
|
c983d00437 | ||
|
|
7bb3fe6b5a | ||
|
|
d202a2fba4 | ||
|
|
72ea38ca4f | ||
|
|
f96d00d9d1 | ||
|
|
f5b98b32f2 | ||
|
|
8aee2a442e | ||
|
|
12eaf2cc75 | ||
|
|
7194743a30 | ||
|
|
5cfe136fb0 | ||
|
|
14a5ed45d9 | ||
|
|
435fe24a81 | ||
|
|
3ffee7c1ff | ||
|
|
97190f715b |
5
.github/workflows/flutter-build.yml
vendored
5
.github/workflows/flutter-build.yml
vendored
@@ -916,8 +916,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Codesign app and create signed dmg
|
- name: Codesign app and create signed dmg
|
||||||
if: env.MACOS_P12_BASE64 != null && env.UPLOAD_ARTIFACT == 'true'
|
if: env.MACOS_P12_BASE64 != null && env.UPLOAD_ARTIFACT == 'true'
|
||||||
env:
|
|
||||||
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
|
|
||||||
run: |
|
run: |
|
||||||
# Patch create-dmg to give more attempts to unmount image
|
# Patch create-dmg to give more attempts to unmount image
|
||||||
CREATE_DMG="$(command -v create-dmg)"
|
CREATE_DMG="$(command -v create-dmg)"
|
||||||
@@ -928,9 +926,10 @@ jobs:
|
|||||||
security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain
|
security unlock-keychain -p ${{ secrets.MACOS_P12_PASSWORD }} rustdesk.keychain
|
||||||
# start sign the rustdesk.app and dmg
|
# start sign the rustdesk.app and dmg
|
||||||
rm -rf *.dmg || true
|
rm -rf *.dmg || true
|
||||||
|
# the identity secret carries its own shell quoting, so expand it inline like the dmg codesign below
|
||||||
bash ./.github/scripts/sign-macos-app.sh \
|
bash ./.github/scripts/sign-macos-app.sh \
|
||||||
./flutter/build/macos/Build/Products/Release/RustDesk.app \
|
./flutter/build/macos/Build/Products/Release/RustDesk.app \
|
||||||
"$MACOS_CODESIGN_IDENTITY" \
|
${{ secrets.MACOS_CODESIGN_IDENTITY }} \
|
||||||
./flutter/macos/Runner/Release.entitlements
|
./flutter/macos/Runner/Release.entitlements
|
||||||
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
|
create-dmg --icon "RustDesk.app" 200 190 --hide-extension "RustDesk.app" --window-size 800 400 --app-drop-link 600 185 rustdesk-${{ env.VERSION }}.dmg ./flutter/build/macos/Build/Products/Release/RustDesk.app
|
||||||
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv
|
codesign --force --options runtime -s ${{ secrets.MACOS_CODESIGN_IDENTITY }} --deep --strict rustdesk-${{ env.VERSION }}.dmg -vvv
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -7198,6 +7198,7 @@ dependencies = [
|
|||||||
"wol-rs",
|
"wol-rs",
|
||||||
"x11-clipboard 0.8.1",
|
"x11-clipboard 0.8.1",
|
||||||
"x11rb 0.12.0",
|
"x11rb 0.12.0",
|
||||||
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ drm-wake = ["drm"]
|
|||||||
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
linux-pkg-config = ["magnum-opus/linux-pkg-config", "scrap/linux-pkg-config"]
|
||||||
unix-file-copy-paste = [
|
unix-file-copy-paste = [
|
||||||
"dep:x11-clipboard",
|
"dep:x11-clipboard",
|
||||||
"dep:x11rb",
|
|
||||||
"dep:percent-encoding",
|
"dep:percent-encoding",
|
||||||
"dep:once_cell",
|
"dep:once_cell",
|
||||||
"clipboard/unix-file-copy-paste",
|
"clipboard/unix-file-copy-paste",
|
||||||
@@ -62,6 +61,7 @@ cfg-if = "1.0"
|
|||||||
lazy_static = "1.4"
|
lazy_static = "1.4"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
repng = "0.2"
|
repng = "0.2"
|
||||||
|
zstd = "0.13"
|
||||||
parity-tokio-ipc = { git = "https://github.com/rustdesk-org/parity-tokio-ipc" }
|
parity-tokio-ipc = { git = "https://github.com/rustdesk-org/parity-tokio-ipc" }
|
||||||
magnum-opus = { git = "https://github.com/rustdesk-org/magnum-opus" }
|
magnum-opus = { git = "https://github.com/rustdesk-org/magnum-opus" }
|
||||||
dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true }
|
dasp = { version = "0.11", features = ["signal", "interpolate-linear", "interpolate"], optional = true }
|
||||||
@@ -191,7 +191,7 @@ evdev = { git="https://github.com/rustdesk-org/evdev" }
|
|||||||
dbus = "0.9"
|
dbus = "0.9"
|
||||||
dbus-crossroads = "0.5"
|
dbus-crossroads = "0.5"
|
||||||
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
x11-clipboard = {git="https://github.com/clslaid/x11-clipboard", branch = "feat/store-batch", optional = true}
|
||||||
x11rb = {version = "0.12", features = ["all-extensions"], optional = true}
|
x11rb = {version = "0.12", features = ["all-extensions"]}
|
||||||
percent-encoding = {version = "2.3", optional = true}
|
percent-encoding = {version = "2.3", optional = true}
|
||||||
once_cell = {version = "1.18", optional = true}
|
once_cell = {version = "1.18", optional = true}
|
||||||
nix = { version = "0.29", features = ["term", "process"]}
|
nix = { version = "0.29", features = ["term", "process"]}
|
||||||
|
|||||||
11
fastlane/metadata/android/es-ES/full_description.txt
Normal file
11
fastlane/metadata/android/es-ES/full_description.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Aplicación de escritorio remoto de código abierto, la alternativa open source a TeamViewer.
|
||||||
|
Código fuente: https://github.com/rustdesk/rustdesk
|
||||||
|
Documentación: https://rustdesk.com/docs/en/manual/mobile/
|
||||||
|
|
||||||
|
Para que un dispositivo remoto controle tu Android mediante el ratón o el tacto, debes permitir que RustDesk utilice el servicio de "Accesibilidad". RustDesk utiliza la API AccessibilityService para implementar el control remoto en Android.
|
||||||
|
|
||||||
|
Además del control remoto, también puedes transferir archivos fácilmente entre dispositivos Android y ordenadores mediante RustDesk.
|
||||||
|
|
||||||
|
Tienes control total de tus datos, sin preocupaciones de seguridad. Puedes utilizar nuestro servidor rendezvous/relay, optar por el autoalojamiento o escribir tu propio servidor rendezvous/relay. El servidor autoalojado es gratuito y de código abierto: https://github.com/rustdesk/rustdesk-server
|
||||||
|
|
||||||
|
Descarga e instala la versión de escritorio desde: https://rustdesk.com — entonces podrás acceder y controlar tu ordenador desde tu teléfono, o controlar tu teléfono desde tu ordenador.
|
||||||
1
fastlane/metadata/android/es-ES/short_description.txt
Normal file
1
fastlane/metadata/android/es-ES/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Aplicación de acceso remoto de código abierto, alternativa a TeamViewer.
|
||||||
11
fastlane/metadata/android/pt-BR/full_description.txt
Normal file
11
fastlane/metadata/android/pt-BR/full_description.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Aplicativo de desktop remoto de código aberto, a alternativa open source ao TeamViewer.
|
||||||
|
Código-fonte: https://github.com/rustdesk/rustdesk
|
||||||
|
Documentação: https://rustdesk.com/docs/pt/client/android/
|
||||||
|
|
||||||
|
Para que um dispositivo remoto controle seu Android via mouse ou toque, você precisa permitir que o RustDesk utilize o serviço de "Acessibilidade". O RustDesk usa a API AccessibilityService para implementar o controle remoto no Android.
|
||||||
|
|
||||||
|
Além do controle remoto, você também pode transferir arquivos entre dispositivos Android e PCs com facilidade usando o RustDesk.
|
||||||
|
|
||||||
|
Você tem controle total dos seus dados, sem preocupações com a segurança. Você pode usar nosso servidor rendezvous/relay, optar pela auto-hospedagem ou criar seu próprio servidor de rendezvous/relay. O servidor auto-hospedado é gratuito e open source: https://github.com/rustdesk/rustdesk-server
|
||||||
|
|
||||||
|
Baixe e instale a versão para desktop em: https://rustdesk.com — então você poderá acessar e controlar seu computador pelo celular ou controlar seu celular pelo computador.
|
||||||
1
fastlane/metadata/android/pt-BR/short_description.txt
Normal file
1
fastlane/metadata/android/pt-BR/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Aplicativo de acesso remoto open source, alternativa ao TeamViewer.
|
||||||
@@ -1101,22 +1101,44 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
final m = Provider.of<ImageModel>(context);
|
final m = Provider.of<ImageModel>(context);
|
||||||
var c = Provider.of<CanvasModel>(context);
|
var c = Provider.of<CanvasModel>(context);
|
||||||
final s = c.scale;
|
final s = c.scale;
|
||||||
|
// CanvasModel caches the DPR and only refreshes it when the view style
|
||||||
|
// changes, so read it live to follow the window across monitors.
|
||||||
|
final dpr = MediaQuery.devicePixelRatioOf(context);
|
||||||
|
|
||||||
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
bool isViewScaled() =>
|
||||||
|
c.viewStyle.style == kRemoteViewStyleAdaptive ||
|
||||||
|
c.viewStyle.style == kRemoteViewStyleCustom;
|
||||||
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
||||||
|
|
||||||
mouseRegion({child}) => Obx(() {
|
mouseRegion({child}) => Obx(() {
|
||||||
double getCursorScale() {
|
double getCursorScale() {
|
||||||
var c = Provider.of<CanvasModel>(context);
|
final cursor = Provider.of<CursorModel>(context);
|
||||||
|
// Predefined artwork must not inherit the cached remote bitmap's DPI.
|
||||||
|
final cache = keyboardEnabled.isTrue
|
||||||
|
? cursor.cache ?? preDefaultCursor.cache
|
||||||
|
: preForbiddenCursor.cache;
|
||||||
|
final peerDpr = cache?.pixelRatio ?? 0;
|
||||||
|
if (isViewScaled() && zoomCursor.isFalse && peerDpr > 0 &&
|
||||||
|
(!isWeb || widget.ffi.ffiModel.pi.platform == kPeerPlatformMacOS)) {
|
||||||
|
// Normalize to logical size; Windows takes physical pixels here.
|
||||||
|
// Web normalizes only macOS's optional Retina image, preserving
|
||||||
|
// the existing unzoomed sizing policy for other hosts.
|
||||||
|
return (isWindows ? dpr : 1.0) / peerDpr;
|
||||||
|
}
|
||||||
|
// Without density, preserve the legacy unzoomed size. Controller
|
||||||
|
// DPR alone cannot determine the remote bitmap's logical size.
|
||||||
|
final imageScale = isViewScaled() && zoomCursor.isTrue
|
||||||
|
? _cursorImageScale(widget.ffi, cursor, useLocalPointer: true)
|
||||||
|
: s;
|
||||||
var cursorScale = 1.0;
|
var cursorScale = 1.0;
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
// debug win10
|
// debug win10
|
||||||
if (zoomCursor.value && isViewAdaptive()) {
|
if (zoomCursor.value && isViewScaled()) {
|
||||||
cursorScale = s * c.devicePixelRatio;
|
cursorScale = imageScale * dpr;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (zoomCursor.value || isViewOriginal()) {
|
if (zoomCursor.value || isViewOriginal()) {
|
||||||
cursorScale = s;
|
cursorScale = imageScale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cursorScale;
|
return cursorScale;
|
||||||
@@ -1149,6 +1171,7 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
child: child);
|
child: child);
|
||||||
});
|
});
|
||||||
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
|
_syncScrollAfterLayout(c);
|
||||||
final paintWidth = c.getDisplayWidth() * s;
|
final paintWidth = c.getDisplayWidth() * s;
|
||||||
final paintHeight = c.getDisplayHeight() * s;
|
final paintHeight = c.getDisplayHeight() * s;
|
||||||
final paintSize = Size(paintWidth, paintHeight);
|
final paintSize = Size(paintWidth, paintHeight);
|
||||||
@@ -1193,6 +1216,20 @@ class _ImagePaintState extends State<ImagePaint> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Layout can change scroll metrics without notification. Compare against this
|
||||||
|
// build's fractions: a delayed refresh may already have changed the model.
|
||||||
|
void _syncScrollAfterLayout(CanvasModel canvas) {
|
||||||
|
// Both supported scrollbar view styles also paint from scroll fractions.
|
||||||
|
if (canvas.scrollStyle != ScrollStyle.scrolledge &&
|
||||||
|
canvas.viewStyle.style != kRemoteViewStyleCustom &&
|
||||||
|
canvas.viewStyle.style != kRemoteViewStyleOriginal) return;
|
||||||
|
final renderedScroll = (canvas.scrollX, canvas.scrollY);
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
canvas.updateScrollAfterLayout(renderedScroll);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildScrollbarNonTextureRender(
|
Widget _buildScrollbarNonTextureRender(
|
||||||
ImageModel m, Size imageSize, double s) {
|
ImageModel m, Size imageSize, double s) {
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
@@ -1384,43 +1421,119 @@ class CursorPaint extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double cx = c.x;
|
final imageOffset = _softwareImageOffset(c);
|
||||||
double cy = c.y;
|
double cx = imageOffset?.dx ?? c.x;
|
||||||
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
|
double cy = imageOffset?.dy ?? c.y;
|
||||||
c.scrollStyle == ScrollStyle.scrollbar) {
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
final rect = c.parent.target!.ffiModel.rect;
|
final rect = c.parent.target!.ffiModel.rect;
|
||||||
if (rect == null) {
|
if (rect == null) {
|
||||||
// unreachable!
|
// unreachable!
|
||||||
debugPrint('unreachable! The displays rect is null.');
|
debugPrint('unreachable! The displays rect is null.');
|
||||||
return Container();
|
return Container();
|
||||||
}
|
}
|
||||||
if (cx < 0) {
|
// Scrollbar and edge scrolling share a layout that ignores canvas pan offsets.
|
||||||
final imageWidth = rect.width * c.scale;
|
final imageWidth = rect.width * c.scale;
|
||||||
cx = -imageWidth * c.scrollX;
|
final imageHeight = rect.height * c.scale;
|
||||||
}
|
// Match the video's integer centering on each fitting axis, even if a
|
||||||
if (cy < 0) {
|
// detached scroll controller left a stale fraction before layout sync.
|
||||||
final imageHeight = rect.height * c.scale;
|
cx = c.size.width < imageWidth
|
||||||
cy = -imageHeight * c.scrollY;
|
? -imageWidth * c.scrollX
|
||||||
}
|
: ((c.size.width - imageWidth) ~/ 2).toDouble();
|
||||||
|
cy = c.size.height < imageHeight
|
||||||
|
? -imageHeight * c.scrollY
|
||||||
|
: ((c.size.height - imageHeight) ~/ 2).toDouble();
|
||||||
}
|
}
|
||||||
|
|
||||||
double x = (m.x - hotx) * c.scale + cx;
|
final image = m.image ?? preDefaultCursor.image;
|
||||||
double y = (m.y - hoty) * c.scale + cy;
|
// Match native registration's logical minimum for density-aware scaled views.
|
||||||
double scale = 1.0;
|
final logicalMinimum = (m.cache?.pixelRatio ?? 0) > 0 &&
|
||||||
final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal;
|
c.viewStyle.style != kRemoteViewStyleOriginal;
|
||||||
if (zoomCursor.value || isViewOriginal) {
|
final nativePixels = isWindows && !logicalMinimum
|
||||||
x = m.x - hotx + cx / c.scale;
|
? MediaQuery.devicePixelRatioOf(context) : 1.0;
|
||||||
y = m.y - hoty + cy / c.scale;
|
// Show remote cursor follows the image scale, independently of Zoom cursor.
|
||||||
scale = c.scale;
|
double scale = _cursorImageScale(c.parent.target!, m);
|
||||||
|
if (image != null && (logicalMinimum || scale * nativePixels != 1.0)) {
|
||||||
|
final sx = kMinCursorSize / (image.width * nativePixels);
|
||||||
|
final sy = kMinCursorSize / (image.height * nativePixels);
|
||||||
|
// Match native resizing: a thin axis must not enlarge the whole cursor.
|
||||||
|
final minimumScale = sx < sy ? sx : sy;
|
||||||
|
if (scale < minimumScale) scale = minimumScale;
|
||||||
}
|
}
|
||||||
|
// Anchor the hotspot to the video position even when the minimum size
|
||||||
|
// makes the artwork scale differ from the video scale.
|
||||||
|
final x = (m.x * c.scale + cx) / scale - hotx;
|
||||||
|
final y = (m.y * c.scale + cy) / scale - hoty;
|
||||||
|
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
painter: ImagePainter(
|
painter: ImagePainter(
|
||||||
image: m.image ?? preDefaultCursor.image,
|
image: image,
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
scale: scale,
|
scale: scale,
|
||||||
|
useIntegerPosition: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Match video-origin rounding: source pixels for software, logical pixels
|
||||||
|
/// for Linux textures. The caller handles scrolling and unrounded origins.
|
||||||
|
/// Keep cursor positions and hotspots fractional.
|
||||||
|
Offset? _softwareImageOffset(CanvasModel canvas) {
|
||||||
|
if (canvas.imageOverflow.isTrue &&
|
||||||
|
canvas.scrollStyle != ScrollStyle.scrollauto) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final ffi = canvas.parent.target!;
|
||||||
|
final peer = ffi.ffiModel;
|
||||||
|
if (ffi.imageModel.useTextureRender || peer.pi.forceTextureRender) {
|
||||||
|
// Match Linux's texture origin without rounding the cursor or hotspot.
|
||||||
|
return isLinux
|
||||||
|
? Offset(canvas.x.toInt().toDouble(), canvas.y.toInt().toDouble())
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
var scale = canvas.scale;
|
||||||
|
final displays = peer.pi.getCurDisplays();
|
||||||
|
if (peer.isPeerLinux && displays.isNotEmpty) scale /= displays[0].scale;
|
||||||
|
// Match the origin used by _buildScrollAutoNonTextureRender's ImagePainter.
|
||||||
|
return Offset(
|
||||||
|
(canvas.x / scale).toInt() * scale, (canvas.y / scale).toInt() * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logical pixels per cursor bitmap pixel, matching the video renderer.
|
||||||
|
/// Linux display geometry, not cursor density, determines this ratio.
|
||||||
|
/// Native mixed-display cursors use mapped local input; overlays use the host
|
||||||
|
/// position. Callers apply minimum size, controller DPR and hotspot offsets.
|
||||||
|
double _cursorImageScale(FFI ffi, CursorModel cursor,
|
||||||
|
{bool useLocalPointer = false}) {
|
||||||
|
final canvas = ffi.canvasModel;
|
||||||
|
final peer = ffi.ffiModel;
|
||||||
|
if (!peer.isPeerLinux) return canvas.scale;
|
||||||
|
if (canvas.imageOverflow.isTrue &&
|
||||||
|
canvas.scrollStyle != ScrollStyle.scrollauto &&
|
||||||
|
!ffi.imageModel.useTextureRender &&
|
||||||
|
!peer.pi.forceTextureRender) {
|
||||||
|
return canvas.scale; // The nontexture scrollbar also paints physical pixels.
|
||||||
|
}
|
||||||
|
final displays = peer.pi.getCurDisplays();
|
||||||
|
if (displays.length == 1) return canvas.scale / displays.first.scale;
|
||||||
|
final rect = peer.rect;
|
||||||
|
if (rect != null) {
|
||||||
|
// Mapped local input is already in host desktop coordinates. CursorModel's
|
||||||
|
// position is relative to rect, so restore its origin for display lookup.
|
||||||
|
// Before the first local movement, both paths use the host position.
|
||||||
|
final position =
|
||||||
|
(useLocalPointer ? ffi.inputModel.remotePointerPosition.value : null) ??
|
||||||
|
Offset(cursor.x + rect.left, cursor.y + rect.top);
|
||||||
|
for (final display in displays) {
|
||||||
|
// Display origins are desktop coordinates; dimensions are physical pixels.
|
||||||
|
// Convert the dimensions so these bounds use the same units as position.
|
||||||
|
if (Rect.fromLTWH(display.x, display.y, display.width / display.scale,
|
||||||
|
display.height / display.scale).contains(position)) {
|
||||||
|
return canvas.scale / display.scale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return canvas.scale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -449,6 +449,11 @@ class InputModel {
|
|||||||
final isPhysicalMouse = false.obs;
|
final isPhysicalMouse = false.obs;
|
||||||
int _lastButtons = 0;
|
int _lastButtons = 0;
|
||||||
Offset lastMousePos = Offset.zero;
|
Offset lastMousePos = Offset.zero;
|
||||||
|
// Latest local pointer position mapped to absolute remote desktop coordinates.
|
||||||
|
// Used to select the native cursor's display scale in Linux All Displays mode,
|
||||||
|
// without waiting for a host position update. Keep it separate from the
|
||||||
|
// host-reported position used to paint the remote cursor.
|
||||||
|
final remotePointerPosition = Rxn<Offset>();
|
||||||
int _lastWheelTsUs = 0;
|
int _lastWheelTsUs = 0;
|
||||||
|
|
||||||
// Wheel acceleration thresholds.
|
// Wheel acceleration thresholds.
|
||||||
@@ -1954,7 +1959,7 @@ class InputModel {
|
|||||||
canvasModel.updateLocalCursor(x, y);
|
canvasModel.updateLocalCursor(x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
return _handlePointerDevicePos(
|
final point = _handlePointerDevicePos(
|
||||||
kind,
|
kind,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -1965,6 +1970,20 @@ class InputModel {
|
|||||||
onExit: onExit,
|
onExit: onExit,
|
||||||
buttons: buttons,
|
buttons: buttons,
|
||||||
);
|
);
|
||||||
|
_rememberRemotePointer(point, isMove);
|
||||||
|
return point;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rememberRemotePointer(Point? point, bool isMove) {
|
||||||
|
final peer = parent.target!.ffiModel;
|
||||||
|
if (point == null ||
|
||||||
|
!isMove ||
|
||||||
|
!(isDesktop || isWebDesktop) ||
|
||||||
|
peer.pi.currentDisplay != kAllDisplayValue ||
|
||||||
|
!peer.isPeerLinux) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remotePointerPosition.value = Offset(point.x.toDouble(), point.y.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isInCurrentWindow(double x, double y) {
|
bool _isInCurrentWindow(double x, double y) {
|
||||||
|
|||||||
@@ -2696,10 +2696,20 @@ class CanvasModel with ChangeNotifier {
|
|||||||
|
|
||||||
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
|
setScrollPercent(scrollPixelPercent.x, scrollPixelPercent.y);
|
||||||
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
|
pushScrollPositionToUI(scrollPixel.x, scrollPixel.y);
|
||||||
|
// A no-op jump emits no notification to refresh the actual scroll fractions.
|
||||||
|
updateScrollPercent();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateScrollAfterLayout((double, double) renderedScroll) {
|
||||||
|
updateScrollPercent();
|
||||||
|
// A delayed refresh may already have changed the model without repainting.
|
||||||
|
if (renderedScroll != (_scrollX, _scrollY)) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
panX(double dx) {
|
panX(double dx) {
|
||||||
_x += dx;
|
_x += dx;
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
@@ -2850,11 +2860,27 @@ class CanvasModel with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// data for cursor
|
// Bound integer cache keys and raster allocation: 4096 squared RGBA is 64 MiB.
|
||||||
|
const _maxCursorRasterSize = 4096;
|
||||||
|
|
||||||
|
bool _validCursorRasterSize(double width, double height,
|
||||||
|
{double rasterScale = 1}) =>
|
||||||
|
width.isFinite && height.isFinite && rasterScale.isFinite &&
|
||||||
|
width > 0 && height > 0 && rasterScale > 0 &&
|
||||||
|
width.ceilToDouble() * rasterScale <= _maxCursorRasterSize &&
|
||||||
|
height.ceilToDouble() * rasterScale <= _maxCursorRasterSize;
|
||||||
|
|
||||||
|
// Scale the host's bitmap and hotspot together. Incorrect source geometry
|
||||||
|
// must be fixed in host capture, independently of the client's sizing policy.
|
||||||
class CursorData {
|
class CursorData {
|
||||||
final String peerId;
|
final String peerId;
|
||||||
final String id;
|
final String id;
|
||||||
final img2.Image image;
|
final img2.Image image;
|
||||||
|
// Borrowed from CursorModel/PredefinedCursor, which own its lifetime.
|
||||||
|
// The plugin clones the handle before starting asynchronous encoding.
|
||||||
|
final ui.Image nativeImage;
|
||||||
|
// Zero preserves legacy sizing for capture backends without density metadata.
|
||||||
|
final double pixelRatio;
|
||||||
double scale;
|
double scale;
|
||||||
Uint8List? data;
|
Uint8List? data;
|
||||||
final double hotxOrigin;
|
final double hotxOrigin;
|
||||||
@@ -2868,6 +2894,8 @@ class CursorData {
|
|||||||
required this.peerId,
|
required this.peerId,
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.image,
|
required this.image,
|
||||||
|
required this.nativeImage,
|
||||||
|
this.pixelRatio = 0,
|
||||||
required this.scale,
|
required this.scale,
|
||||||
required this.data,
|
required this.data,
|
||||||
required this.hotxOrigin,
|
required this.hotxOrigin,
|
||||||
@@ -2879,20 +2907,35 @@ class CursorData {
|
|||||||
|
|
||||||
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
int _doubleToInt(double v) => (v * 10e6).round().toInt();
|
||||||
|
|
||||||
double _checkUpdateScale(double scale) {
|
// Keep the minimum-size policy here. Native callers let the plugin rasterize
|
||||||
double oldScale = this.scale;
|
// the original ui.Image; Web keeps the encoded-image resizing path.
|
||||||
if (scale != 1.0) {
|
double? _validatedScale(double scale,
|
||||||
// Update data if scale changed.
|
{required bool useLegacyMinimum, required double rasterScale}) {
|
||||||
final tgtWidth = (width * scale).toInt();
|
if (!scale.isFinite || scale <= 0) {
|
||||||
final tgtHeight = (width * scale).toInt();
|
debugPrint('Rejected cursor $id: invalid scale $scale');
|
||||||
if (tgtWidth < kMinCursorSize || tgtHeight < kMinCursorSize) {
|
return null;
|
||||||
double sw = kMinCursorSize.toDouble() / width;
|
}
|
||||||
double sh = kMinCursorSize.toDouble() / height;
|
// Preserve unscaled legacy artwork, but never enlarge a thin cursor just
|
||||||
scale = sw < sh ? sh : sw;
|
// to make its short edge reach the visibility minimum.
|
||||||
}
|
if (!useLegacyMinimum || scale != 1.0) {
|
||||||
|
scale = max(scale, kMinCursorSize / max(width, height));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_doubleToInt(oldScale) != _doubleToInt(scale)) {
|
if (!_validCursorRasterSize(width * scale, height * scale,
|
||||||
|
rasterScale: rasterScale)) {
|
||||||
|
debugPrint('Rejected cursor $id: raster ${width * scale}x${height * scale} '
|
||||||
|
'at pixel ratio $rasterScale exceeds $_maxCursorRasterSize');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _checkUpdateScale(double scale, {required bool resizeImage}) {
|
||||||
|
double oldScale = this.scale;
|
||||||
|
// Web's long-edge minimum can round a thin axis below one raster pixel.
|
||||||
|
final webWidth = max(1, (width * scale).round());
|
||||||
|
final webHeight = max(1, (height * scale).round());
|
||||||
|
if (resizeImage && _doubleToInt(oldScale) != _doubleToInt(scale)) {
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
data = img2
|
data = img2
|
||||||
.copyResize(
|
.copyResize(
|
||||||
@@ -2907,8 +2950,8 @@ class CursorData {
|
|||||||
img2.encodePng(
|
img2.encodePng(
|
||||||
img2.copyResize(
|
img2.copyResize(
|
||||||
image,
|
image,
|
||||||
width: (width * scale).toInt(),
|
width: isWeb ? webWidth : (width * scale).toInt(),
|
||||||
height: (height * scale).toInt(),
|
height: isWeb ? webHeight : (height * scale).toInt(),
|
||||||
interpolation: img2.Interpolation.average,
|
interpolation: img2.Interpolation.average,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2919,12 +2962,22 @@ class CursorData {
|
|||||||
this.scale = scale;
|
this.scale = scale;
|
||||||
hotx = hotxOrigin * scale;
|
hotx = hotxOrigin * scale;
|
||||||
hoty = hotyOrigin * scale;
|
hoty = hotyOrigin * scale;
|
||||||
|
if (isWeb) {
|
||||||
|
// CSS hotspots must follow the actual rounded PNG dimensions.
|
||||||
|
hotx = hotxOrigin * webWidth / width;
|
||||||
|
hoty = hotyOrigin * webHeight / height;
|
||||||
|
}
|
||||||
return scale;
|
return scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
String updateGetKey(double scale) {
|
String? updateGetKey(double scale,
|
||||||
scale = _checkUpdateScale(scale);
|
{bool resizeImage = true, bool useLegacyMinimum = true,
|
||||||
return '${peerId}_${id}_${_doubleToInt(width * scale)}_${_doubleToInt(height * scale)}';
|
double rasterScale = 1}) {
|
||||||
|
final effectiveScale = _validatedScale(scale,
|
||||||
|
useLegacyMinimum: useLegacyMinimum, rasterScale: rasterScale);
|
||||||
|
if (effectiveScale == null) return null;
|
||||||
|
_checkUpdateScale(effectiveScale, resizeImage: resizeImage);
|
||||||
|
return '${peerId}_${id}_${_doubleToInt(width * effectiveScale)}_${_doubleToInt(height * effectiveScale)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2964,7 +3017,8 @@ class PredefinedCursor {
|
|||||||
CursorData? get cache => _cache;
|
CursorData? get cache => _cache;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
_image2 = img2.decodePng(base64Decode(png));
|
final pngBytes = base64Decode(png);
|
||||||
|
_image2 = img2.decodePng(pngBytes);
|
||||||
if (_image2 != null) {
|
if (_image2 != null) {
|
||||||
// The png type of forbidden cursor image is `PngColorType.indexed`.
|
// The png type of forbidden cursor image is `PngColorType.indexed`.
|
||||||
if (id == kPreForbiddenCursorId) {
|
if (id == kPreForbiddenCursorId) {
|
||||||
@@ -2972,17 +3026,20 @@ class PredefinedCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
() async {
|
() async {
|
||||||
final defaultImg = _image2!;
|
|
||||||
// This function is called only one time, no need to care about the performance.
|
|
||||||
Uint8List data = defaultImg.getBytes(order: img2.ChannelOrder.rgba);
|
|
||||||
_image?.dispose();
|
_image?.dispose();
|
||||||
_image = await img.decodeImageFromPixels(
|
// Native registration uses this ui.Image. The RGBA bytes from img2 are
|
||||||
data, defaultImg.width, defaultImg.height, ui.PixelFormat.rgba8888);
|
// straight alpha, but PixelFormat.rgba8888 requires premultiplied alpha.
|
||||||
if (_image == null) {
|
// Decode the PNG directly to preserve translucent cursor colors.
|
||||||
print("decodeImageFromPixels failed, pre-defined cursor $id");
|
final codec = await ui.instantiateImageCodec(pngBytes);
|
||||||
return;
|
final ui.Image nativeImage;
|
||||||
|
try {
|
||||||
|
nativeImage = (await codec.getNextFrame()).image;
|
||||||
|
} finally {
|
||||||
|
codec.dispose();
|
||||||
}
|
}
|
||||||
|
_image = nativeImage;
|
||||||
double scale = 1.0;
|
double scale = 1.0;
|
||||||
|
final Uint8List data;
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
|
data = _image2!.getBytes(order: img2.ChannelOrder.bgra);
|
||||||
} else {
|
} else {
|
||||||
@@ -2993,6 +3050,7 @@ class PredefinedCursor {
|
|||||||
peerId: '',
|
peerId: '',
|
||||||
id: id,
|
id: id,
|
||||||
image: _image2!.clone(),
|
image: _image2!.clone(),
|
||||||
|
nativeImage: nativeImage,
|
||||||
scale: scale,
|
scale: scale,
|
||||||
data: data,
|
data: data,
|
||||||
hotxOrigin:
|
hotxOrigin:
|
||||||
@@ -3428,16 +3486,44 @@ class CursorModel with ChangeNotifier {
|
|||||||
final hoty = double.parse(evt['hoty']);
|
final hoty = double.parse(evt['hoty']);
|
||||||
final width = int.parse(evt['width']);
|
final width = int.parse(evt['width']);
|
||||||
final height = int.parse(evt['height']);
|
final height = int.parse(evt['height']);
|
||||||
|
// Rust validates native packets; Web receives them directly from JavaScript.
|
||||||
|
if (isWeb && !_validCursorRasterSize(width.toDouble(), height.toDouble())) {
|
||||||
|
debugPrint('Rejected cursor $id: invalid source size ${width}x$height');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final pixelRatio = double.tryParse(evt['scale'] ?? '0');
|
||||||
|
if (pixelRatio == null ||
|
||||||
|
(isWeb && (!pixelRatio.isFinite || pixelRatio < 0 ||
|
||||||
|
(pixelRatio > 0 &&
|
||||||
|
!_validCursorRasterSize(width / pixelRatio, height / pixelRatio))))) {
|
||||||
|
debugPrint('Rejected cursor $id: invalid pixel ratio ${evt['scale']}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
List<dynamic> colors = json.decode(evt['colors']);
|
List<dynamic> colors = json.decode(evt['colors']);
|
||||||
|
const bytesPerPixel = 4;
|
||||||
|
if (isWeb && colors.length != width * height * bytesPerPixel) {
|
||||||
|
debugPrint('Rejected cursor $id: invalid RGBA length ${colors.length}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final rgba = Uint8List.fromList(colors.map((s) => s as int).toList());
|
final rgba = Uint8List.fromList(colors.map((s) => s as int).toList());
|
||||||
final image = await img.decodeImageFromPixels(
|
final ui.Image? image;
|
||||||
rgba, width, height, ui.PixelFormat.rgba8888);
|
final platform = parent.target?.ffiModel.pi.platform;
|
||||||
|
if (!isWeb &&
|
||||||
|
(platform == kPeerPlatformMacOS || platform == kPeerPlatformWindows)) {
|
||||||
|
image = await _decodeStraightAlphaCursor(rgba, width, height);
|
||||||
|
} else {
|
||||||
|
image = await img.decodeImageFromPixels(
|
||||||
|
rgba, width, height, ui.PixelFormat.rgba8888);
|
||||||
|
}
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (await _updateCache(rgba, image, id, hotx, hoty, width, height)) {
|
if (await _updateCache(rgba, image, id, hotx, hoty, width, height,
|
||||||
|
pixelRatio: pixelRatio)) {
|
||||||
_images[id]?.item1.dispose();
|
_images[id]?.item1.dispose();
|
||||||
_images[id] = Tuple3(image, hotx, hoty);
|
_images[id] = Tuple3(image, hotx, hoty);
|
||||||
|
} else {
|
||||||
|
image.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last cursor data.
|
// Update last cursor data.
|
||||||
@@ -3445,6 +3531,22 @@ class CursorModel with ChangeNotifier {
|
|||||||
_updateCurData();
|
_updateCurData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ui.Image?> _decodeStraightAlphaCursor(
|
||||||
|
Uint8List rgba, int width, int height) async {
|
||||||
|
// macOS and Win32 capture send straight alpha; XFixes/DRM are premultiplied.
|
||||||
|
// Convert a copy for native ui.Image, preserving the wire and PNG cache colors.
|
||||||
|
final source = img2.Image.fromBytes(
|
||||||
|
width: width, height: height, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||||
|
for (final pixel in source) {
|
||||||
|
final opacity = pixel.a / pixel.maxChannelValue;
|
||||||
|
pixel.r = (pixel.r * opacity).round();
|
||||||
|
pixel.g = (pixel.g * opacity).round();
|
||||||
|
pixel.b = (pixel.b * opacity).round();
|
||||||
|
}
|
||||||
|
return img.decodeImageFromPixels(
|
||||||
|
source.getBytes(), width, height, ui.PixelFormat.rgba8888);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _updateCache(
|
Future<bool> _updateCache(
|
||||||
Uint8List rgba,
|
Uint8List rgba,
|
||||||
ui.Image image,
|
ui.Image image,
|
||||||
@@ -3452,8 +3554,9 @@ class CursorModel with ChangeNotifier {
|
|||||||
double hotx,
|
double hotx,
|
||||||
double hoty,
|
double hoty,
|
||||||
int w,
|
int w,
|
||||||
int h,
|
int h, {
|
||||||
) async {
|
required double pixelRatio,
|
||||||
|
}) async {
|
||||||
Uint8List? data;
|
Uint8List? data;
|
||||||
img2.Image imgOrigin = img2.Image.fromBytes(
|
img2.Image imgOrigin = img2.Image.fromBytes(
|
||||||
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
width: w, height: h, bytes: rgba.buffer, order: img2.ChannelOrder.rgba);
|
||||||
@@ -3463,6 +3566,7 @@ class CursorModel with ChangeNotifier {
|
|||||||
ByteData? imgBytes =
|
ByteData? imgBytes =
|
||||||
await image.toByteData(format: ui.ImageByteFormat.png);
|
await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
if (imgBytes == null) {
|
if (imgBytes == null) {
|
||||||
|
debugPrint('Unable to encode cursor $id as PNG');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
data = imgBytes.buffer.asUint8List();
|
data = imgBytes.buffer.asUint8List();
|
||||||
@@ -3471,6 +3575,8 @@ class CursorModel with ChangeNotifier {
|
|||||||
peerId: peerId,
|
peerId: peerId,
|
||||||
id: id,
|
id: id,
|
||||||
image: imgOrigin,
|
image: imgOrigin,
|
||||||
|
nativeImage: image,
|
||||||
|
pixelRatio: pixelRatio,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
data: data,
|
data: data,
|
||||||
hotxOrigin: hotx,
|
hotxOrigin: hotx,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter_custom_cursor/cursor_manager.dart'
|
import 'package:flutter_custom_cursor/cursor_manager.dart'
|
||||||
as custom_cursor_manager;
|
as custom_cursor_manager;
|
||||||
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
|
import 'package:flutter_custom_cursor/flutter_custom_cursor.dart';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart' show WidgetsBinding;
|
||||||
|
|
||||||
|
import 'package:flutter_hbb/common.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
import 'package:flutter_hbb/models/model.dart';
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
|
||||||
deleteCustomCursor(String key) =>
|
deleteCustomCursor(String key) =>
|
||||||
@@ -16,29 +22,49 @@ MouseCursor buildCursorOfCache(
|
|||||||
if (cache == null) {
|
if (cache == null) {
|
||||||
return MouseCursor.defer;
|
return MouseCursor.defer;
|
||||||
} else {
|
} else {
|
||||||
final key = cache.updateGetKey(scale);
|
// Include the live DPR so moving between monitors rebuilds the native
|
||||||
|
// bitmap even when the remote view scale has not changed.
|
||||||
|
final dpr = WidgetsBinding
|
||||||
|
.instance.platformDispatcher.views.single.devicePixelRatio;
|
||||||
|
// Keep Original and older peers' pixel units and scale-1 behavior.
|
||||||
|
// Resizing uses a long-edge minimum regardless of source density.
|
||||||
|
final legacyMinimum = cache.pixelRatio == 0 ||
|
||||||
|
cursor.parent.target?.canvasModel.viewStyle.style == kRemoteViewStyleOriginal;
|
||||||
|
// The minimum is logical, while Windows callers pass a physical scale.
|
||||||
|
final effectiveScale = !legacyMinimum && isWindows
|
||||||
|
? math.max(scale, kMinCursorSize * dpr / math.max(cache.width, cache.height))
|
||||||
|
: scale;
|
||||||
|
final cacheKey = cache.updateGetKey(effectiveScale, resizeImage: false,
|
||||||
|
useLegacyMinimum: legacyMinimum,
|
||||||
|
rasterScale: isWindows ? 1 : (isLinux ? dpr.ceilToDouble() : dpr));
|
||||||
|
if (cacheKey == null) return MouseCursor.defer;
|
||||||
|
final key = '${cacheKey}_$dpr';
|
||||||
if (!cursor.cachedKeys.contains(key)) {
|
if (!cursor.cachedKeys.contains(key)) {
|
||||||
// data should be checked here, because it may be changed after `updateGetKey()`
|
|
||||||
final data = cache.data;
|
|
||||||
if (data == null) {
|
|
||||||
return MouseCursor.defer;
|
|
||||||
}
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
|
"Register custom cursor with key $key (${cache.hotx},${cache.hoty})");
|
||||||
// [Safety]
|
unawaited(custom_cursor_manager.CursorManager.instance
|
||||||
// It's ok to call async registerCursor in current synchronous context,
|
.registerCursorImage(
|
||||||
// because activating the cursor is also an async call and will always
|
name: key,
|
||||||
// be executed after this.
|
image: cache.nativeImage,
|
||||||
custom_cursor_manager.CursorManager.instance
|
hotSpot: Offset(cache.hotxOrigin, cache.hotyOrigin),
|
||||||
.registerCursor(custom_cursor_manager.CursorData()
|
// Windows callers already express scale in physical pixels.
|
||||||
..name = key
|
// The plugin takes logical scale and applies DPR during rasterization.
|
||||||
..buffer = data
|
scale: isWindows ? cache.scale / dpr : cache.scale,
|
||||||
..width = (cache.width * cache.scale).toInt()
|
devicePixelRatio: dpr,
|
||||||
..height = (cache.height * cache.scale).toInt()
|
)
|
||||||
..hotX = cache.hotx
|
.then<void>((_) {}, onError: (Object error, StackTrace stack) {
|
||||||
..hotY = cache.hoty);
|
cursor.cachedKeys.remove(key);
|
||||||
|
FlutterError.reportError(FlutterErrorDetails(
|
||||||
|
exception: error,
|
||||||
|
stack: stack,
|
||||||
|
library: 'native cursor',
|
||||||
|
context: ErrorDescription('registering cursor $key')));
|
||||||
|
}));
|
||||||
cursor.addKey(key);
|
cursor.addKey(key);
|
||||||
}
|
}
|
||||||
return FlutterCustomMemoryImageCursor(key: key);
|
return FlutterCustomMemoryImageCursor(
|
||||||
|
key: key,
|
||||||
|
registrationToken: custom_cursor_manager.CursorManager.instance
|
||||||
|
.registrationTokenFor(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,12 +96,14 @@ class ImagePainter extends CustomPainter {
|
|||||||
required this.x,
|
required this.x,
|
||||||
required this.y,
|
required this.y,
|
||||||
required this.scale,
|
required this.scale,
|
||||||
|
this.useIntegerPosition = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.Image? image;
|
ui.Image? image;
|
||||||
double x;
|
double x;
|
||||||
double y;
|
double y;
|
||||||
double scale;
|
double scale;
|
||||||
|
final bool useIntegerPosition;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
@@ -122,8 +124,10 @@ class ImagePainter extends CustomPainter {
|
|||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
paint.filterQuality = FilterQuality.high;
|
paint.filterQuality = FilterQuality.high;
|
||||||
}
|
}
|
||||||
canvas.drawImage(
|
final position = useIntegerPosition
|
||||||
image!, Offset(x.toInt().toDouble(), y.toInt().toDouble()), paint);
|
? Offset(x.toInt().toDouble(), y.toInt().toDouble())
|
||||||
|
: Offset(x, y);
|
||||||
|
canvas.drawImage(image!, position, paint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:js' as js;
|
import 'dart:js' as js;
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -52,8 +53,9 @@ class CursorManager {
|
|||||||
'cursor',
|
'cursor',
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'url': cursorData.url,
|
'url': cursorData.url,
|
||||||
'hotx': cursorData.hotX.toInt(),
|
// Rounding must keep the hotspot inside even a one-pixel raster.
|
||||||
'hoty': cursorData.hotY.toInt(),
|
'hotx': cursorData.hotX.round().clamp(0, cursorData.width - 1),
|
||||||
|
'hoty': cursorData.hotY.round().clamp(0, cursorData.height - 1),
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,10 @@ MouseCursor buildCursorOfCache(
|
|||||||
if (cache == null) {
|
if (cache == null) {
|
||||||
return MouseCursor.defer;
|
return MouseCursor.defer;
|
||||||
} else {
|
} else {
|
||||||
final key = cache.updateGetKey(scale);
|
// A short-edge minimum can enlarge thin artwork beyond CSS cursor limits.
|
||||||
|
// Keep unzoomed images unchanged and use the long edge when resizing.
|
||||||
|
final key = cache.updateGetKey(scale, useLegacyMinimum: scale == 1.0);
|
||||||
|
if (key == null) return MouseCursor.defer;
|
||||||
if (!cursor.cachedKeys.contains(key)) {
|
if (!cursor.cachedKeys.contains(key)) {
|
||||||
// data should be checked here, because it may be changed after `updateGetKey()`
|
// data should be checked here, because it may be changed after `updateGetKey()`
|
||||||
final data = cache.data;
|
final data = cache.data;
|
||||||
@@ -116,8 +121,8 @@ MouseCursor buildCursorOfCache(
|
|||||||
CursorManager.instance.registerCursor(CursorData(
|
CursorManager.instance.registerCursor(CursorData(
|
||||||
key: key,
|
key: key,
|
||||||
url: 'data:image/rgba;base64,${base64Encode(data)}',
|
url: 'data:image/rgba;base64,${base64Encode(data)}',
|
||||||
width: (cache.width * cache.scale).toInt(),
|
width: max(1, (cache.width * cache.scale).round()),
|
||||||
height: (cache.height * cache.scale).toInt(),
|
height: max(1, (cache.height * cache.scale).round()),
|
||||||
hotX: cache.hotx,
|
hotX: cache.hotx,
|
||||||
hotY: cache.hoty));
|
hotY: cache.hoty));
|
||||||
cursor.addKey(key);
|
cursor.addKey(key);
|
||||||
|
|||||||
@@ -522,9 +522,9 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
ref: "ad940aaac3aada70958df2de761adda1a71aa2c4"
|
||||||
resolved-ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
resolved-ref: "ad940aaac3aada70958df2de761adda1a71aa2c4"
|
||||||
url: "https://github.com/rustdesk-org/flutter_custom_cursor"
|
url: "https://github.com/fufesou/flutter_custom_cursor"
|
||||||
source: git
|
source: git
|
||||||
version: "0.0.3"
|
version: "0.0.3"
|
||||||
flutter_gpu_texture_renderer:
|
flutter_gpu_texture_renderer:
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ dependencies:
|
|||||||
freezed_annotation: ^2.0.3
|
freezed_annotation: ^2.0.3
|
||||||
flutter_custom_cursor:
|
flutter_custom_cursor:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/rustdesk-org/flutter_custom_cursor
|
url: https://github.com/fufesou/flutter_custom_cursor
|
||||||
ref: db63b785c38153603e9fb84b50d3ec46f0d7e05e
|
ref: ad940aaac3aada70958df2de761adda1a71aa2c4
|
||||||
window_size:
|
window_size:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/21pages/flutter-desktop-embedding.git
|
url: https://github.com/21pages/flutter-desktop-embedding.git
|
||||||
|
|||||||
149
flutter/test/cursor_density_validation_test.dart
Normal file
149
flutter/test/cursor_density_validation_test.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart' as common;
|
||||||
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
_rasterBoundsTests();
|
||||||
|
final registrations = <Map<dynamic, dynamic>>[];
|
||||||
|
captureNativeCursors(registrations);
|
||||||
|
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||||
|
for (final (density, width, dpr) in [
|
||||||
|
(null, 4, 1.0),
|
||||||
|
('0', 4, 2.0),
|
||||||
|
(null, 32, 2.0),
|
||||||
|
('0', 32, 1.0),
|
||||||
|
]) {
|
||||||
|
testWidgets(
|
||||||
|
'legacy $style density=$density ${width}x32 DPR=$dpr',
|
||||||
|
(tester) => tester.runAsync(() => checkDensity(
|
||||||
|
tester, (style, density), registrations,
|
||||||
|
sourceSize: Size(width.toDouble(), 32), dpr: dpr)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _rasterBoundsTests() {
|
||||||
|
for (final (width, height, scale, legacy, rasterScale) in [
|
||||||
|
(32, 32, 1e300, false, 1.0),
|
||||||
|
(32, 32, 100.0, false, 2.0),
|
||||||
|
]) {
|
||||||
|
test('rejects raster ${width}x$height scale=$scale before key creation',
|
||||||
|
() async {
|
||||||
|
final image = await createTestImage(width: width, height: height);
|
||||||
|
addTearDown(image.dispose);
|
||||||
|
final data = CursorData(
|
||||||
|
peerId: 'bounds',
|
||||||
|
id: 'bounds',
|
||||||
|
image: img.Image(width: width, height: height, numChannels: 4),
|
||||||
|
nativeImage: image,
|
||||||
|
scale: 1,
|
||||||
|
data: null,
|
||||||
|
hotxOrigin: 0,
|
||||||
|
hotyOrigin: 0,
|
||||||
|
width: width,
|
||||||
|
height: height);
|
||||||
|
final initial = data.updateGetKey(1, resizeImage: false);
|
||||||
|
expect(
|
||||||
|
data.updateGetKey(scale,
|
||||||
|
resizeImage: false,
|
||||||
|
useLegacyMinimum: legacy,
|
||||||
|
rasterScale: rasterScale),
|
||||||
|
isNull);
|
||||||
|
expect(data.scale, 1);
|
||||||
|
expect(data.updateGetKey(1, resizeImage: false), initial);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> checkDensity(WidgetTester tester, (String, String?) input,
|
||||||
|
List<Map<dynamic, dynamic>> registrations,
|
||||||
|
{Size sourceSize = const Size(32, 32), double dpr = 1}) async {
|
||||||
|
final (style, density) = input;
|
||||||
|
final id = '$style-$density';
|
||||||
|
tester.view.devicePixelRatio = dpr;
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final canvas = CursorTestCanvas(dpr, style: style, scale: 0.5);
|
||||||
|
final ffi = CursorTestFFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
|
||||||
|
final cursor = CursorModel(WeakReference(ffi))..id = id;
|
||||||
|
addTearDown(() async {
|
||||||
|
expect(cursor.parent.target, same(ffi));
|
||||||
|
for (final key in cursor.cachedKeys) {
|
||||||
|
await deleteCustomCursor(key);
|
||||||
|
}
|
||||||
|
cursor.disposeImages();
|
||||||
|
cursor.dispose();
|
||||||
|
canvas.dispose();
|
||||||
|
});
|
||||||
|
await cursor.updateCursorData(_cursorEvent(id, density, size: sourceSize));
|
||||||
|
expect(cursor.cache!.pixelRatio, 0);
|
||||||
|
await _paintCursor(tester, ffi, cursor);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
for (final key in cursor.cachedKeys) {
|
||||||
|
await CursorManager.instance.ensureCursorRegistered(key);
|
||||||
|
}
|
||||||
|
expect(registrations, hasLength(1));
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
expect(cursor.cache!.scale, 1.0);
|
||||||
|
_expectLegacyRaster(registrations.single, sourceSize, dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _paintCursor(
|
||||||
|
WidgetTester tester, CursorTestFFI ffi, CursorModel cursor) async {
|
||||||
|
await tester.pumpWidget(MediaQuery(
|
||||||
|
data: MediaQueryData(devicePixelRatio: ffi.canvasModel.devicePixelRatio),
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ImageModel>(create: (_) => CursorTestImage()),
|
||||||
|
ChangeNotifierProvider<CanvasModel>.value(value: ffi.canvasModel),
|
||||||
|
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||||
|
],
|
||||||
|
child: ImagePaint(
|
||||||
|
ffi: ffi,
|
||||||
|
id: 'density-review',
|
||||||
|
zoomCursor: false.obs,
|
||||||
|
cursorOverImage: true.obs,
|
||||||
|
keyboardEnabled: true.obs,
|
||||||
|
remoteCursorMoved: false.obs)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _expectLegacyRaster(Map<dynamic, dynamic> args, Size size, double dpr) {
|
||||||
|
// Base unzoomed sizing is logical on macOS/GTK, physical on Windows.
|
||||||
|
final rasterScale = common.isWindows ? 1.0 : dpr;
|
||||||
|
final width = (size.width * rasterScale).toInt();
|
||||||
|
final height = (size.height * rasterScale).toInt();
|
||||||
|
final bufferWidth = common.isLinux ? size.longestSide * rasterScale : width;
|
||||||
|
expect((args['width'], args['height']), (bufferWidth, height));
|
||||||
|
expect((args['hotX'], args['hotY']),
|
||||||
|
((size.width ~/ 2) * rasterScale, (size.height ~/ 2) * rasterScale));
|
||||||
|
expect(args['imagePixelRatio'], dpr);
|
||||||
|
final decoded = decodeNativeCursorRaster(args);
|
||||||
|
expect((decoded.width, decoded.height), (bufferWidth, height));
|
||||||
|
expect(decoded.getPixel(width - 1, height - 1).a, 255);
|
||||||
|
if (bufferWidth > width) expect(decoded.getPixel(width, 0).a, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _cursorEvent(String id, String? density,
|
||||||
|
{Size size = const Size(32, 32)}) =>
|
||||||
|
{
|
||||||
|
'id': id,
|
||||||
|
'width': '${size.width.toInt()}',
|
||||||
|
'height': '${size.height.toInt()}',
|
||||||
|
'hotx': '${size.width ~/ 2}',
|
||||||
|
'hoty': '${size.height ~/ 2}',
|
||||||
|
if (density != null) 'scale': density,
|
||||||
|
'colors': jsonEncode(
|
||||||
|
List<int>.filled((size.width * size.height * 4).toInt(), 255)),
|
||||||
|
};
|
||||||
236
flutter/test/cursor_dpi_policy_test.dart
Normal file
236
flutter/test/cursor_dpi_policy_test.dart
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_custom_cursor/cursor_manager.dart' show CursorManager;
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
class _Cursor extends CursorModel {
|
||||||
|
_Cursor(this.cache, this._ffi) : super(WeakReference(_ffi));
|
||||||
|
|
||||||
|
final FFI _ffi;
|
||||||
|
@override
|
||||||
|
WeakReference<FFI> get parent => WeakReference(_ffi);
|
||||||
|
|
||||||
|
@override
|
||||||
|
CursorData cache;
|
||||||
|
@override
|
||||||
|
double get hotx => cache.hotxOrigin;
|
||||||
|
@override
|
||||||
|
double get hoty => cache.hotyOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LinuxDisplay extends Display {
|
||||||
|
@override
|
||||||
|
double get scale => 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CursorData> _data(int density, String id,
|
||||||
|
{(int, int) source = (9, 18)}) async {
|
||||||
|
final bitmapDensity = density == 0 ? 1 : density;
|
||||||
|
final image = await createTestImage(
|
||||||
|
width: source.$1 * bitmapDensity, height: source.$2 * bitmapDensity);
|
||||||
|
return CursorData(
|
||||||
|
peerId: 'dpi-policy',
|
||||||
|
id: id,
|
||||||
|
image: img.Image(width: image.width, height: image.height, numChannels: 4),
|
||||||
|
nativeImage: image,
|
||||||
|
scale: 1,
|
||||||
|
data: Uint8List.fromList([1, 2]),
|
||||||
|
hotxOrigin: (source.$1 ~/ 2) * bitmapDensity.toDouble(),
|
||||||
|
hotyOrigin: (source.$2 ~/ 2) * bitmapDensity.toDouble(),
|
||||||
|
width: image.width,
|
||||||
|
height: image.height,
|
||||||
|
pixelRatio: density.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final view = binding.platformDispatcher.views.single;
|
||||||
|
final windows = Platform.isWindows;
|
||||||
|
final registrations = <Map<dynamic, dynamic>>[];
|
||||||
|
captureNativeCursors(registrations);
|
||||||
|
tearDown(view.resetDevicePixelRatio);
|
||||||
|
for (final forbidden in [false, true]) {
|
||||||
|
test('predefined cursor forbidden=$forbidden preserves native RGBA', () {
|
||||||
|
view.devicePixelRatio = 1;
|
||||||
|
return _checkPredefinedCursor(
|
||||||
|
forbidden ? preForbiddenCursor : preDefaultCursor, registrations);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (final testCase in [
|
||||||
|
(kRemoteViewStyleAdaptive, false, 0, 0.25, 1.0),
|
||||||
|
(kRemoteViewStyleCustom, false, 0, 0.25, 1.0),
|
||||||
|
(kRemoteViewStyleAdaptive, false, 1, 0.25, windows ? 2.0 : 1.0),
|
||||||
|
(kRemoteViewStyleAdaptive, false, 2, 0.25, windows ? 1.0 : 0.5),
|
||||||
|
(kRemoteViewStyleCustom, false, 2, 0.25, windows ? 1.0 : 0.5),
|
||||||
|
(kRemoteViewStyleAdaptive, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
|
||||||
|
(kRemoteViewStyleCustom, true, 2, 0.25, windows ? 2 / 3 : 1 / 3),
|
||||||
|
(kRemoteViewStyleOriginal, false, 1, 0.5, windows ? 1.0 : 2 / 3),
|
||||||
|
(kRemoteViewStyleOriginal, false, 2, 0.5, windows ? 1.0 : 0.5),
|
||||||
|
]) {
|
||||||
|
testWidgets(
|
||||||
|
'${testCase.$1} zoom=${testCase.$2} peerDPR=${testCase.$3}',
|
||||||
|
(tester) => tester
|
||||||
|
.runAsync(() => _checkPolicy(tester, testCase, registrations)));
|
||||||
|
}
|
||||||
|
test('live DPR changes invalidate a cached native cursor',
|
||||||
|
() => _checkDprChange(view, registrations));
|
||||||
|
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||||
|
final dpr = style == kRemoteViewStyleAdaptive ? 1.0 : 2.0;
|
||||||
|
final source = style == kRemoteViewStyleAdaptive ? (64, 4) : (4, 64);
|
||||||
|
testWidgets('$style unknown-density thin cursor preserves zoom', (tester) =>
|
||||||
|
tester.runAsync(() => _checkPolicy(tester,
|
||||||
|
(style, true, 0, 0.5, windows ? 0.5 * dpr : 0.5), registrations,
|
||||||
|
dpr: dpr, source: source)));
|
||||||
|
testWidgets('$style forbidden cursor ignores remote DPR', (tester) =>
|
||||||
|
tester.runAsync(() => _checkPolicy(tester,
|
||||||
|
(style, false, 2, 0.25, 0.5), registrations, dpr: 1)));
|
||||||
|
testWidgets('Linux $style zoom follows video pixels', (tester) => tester.runAsync(
|
||||||
|
() => _checkPolicy(tester, (style, true, 2, 1.0, windows ? 1.0 : 0.5),
|
||||||
|
registrations, linux: true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPolicy(
|
||||||
|
WidgetTester tester,
|
||||||
|
(String, bool, int, double, double) testCase,
|
||||||
|
List<Map<dynamic, dynamic>> registrations,
|
||||||
|
{bool linux = false, double dpr = 2, (int, int) source = (9, 18)}) async {
|
||||||
|
final (style, zoom, density, viewScale, expectedScale) = testCase;
|
||||||
|
tester.view.devicePixelRatio = dpr;
|
||||||
|
final data = await _data(density, '$style-$zoom-$density', source: source);
|
||||||
|
final originalBytes = data.data;
|
||||||
|
// A stale cached DPR must not affect the cursor when the window moves.
|
||||||
|
final canvas = CursorTestCanvas(1, style: style, scale: viewScale);
|
||||||
|
final ffi = CursorTestFFI(canvas);
|
||||||
|
ffi.ffiModel.isPeerLinux = linux;
|
||||||
|
if (linux) ffi.ffiModel.pi.displays.add(_LinuxDisplay());
|
||||||
|
final cursor = _Cursor(data, ffi);
|
||||||
|
final keyboardEnabled = true.obs;
|
||||||
|
await tester.pumpWidget(MediaQuery(
|
||||||
|
data: MediaQueryData(devicePixelRatio: dpr),
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ImageModel>(create: (_) => CursorTestImage()),
|
||||||
|
ChangeNotifierProvider<CanvasModel>.value(value: canvas),
|
||||||
|
ChangeNotifierProvider<CursorModel>.value(value: cursor),
|
||||||
|
],
|
||||||
|
child: ImagePaint(
|
||||||
|
ffi: ffi,
|
||||||
|
id: 'dpi-policy',
|
||||||
|
zoomCursor: zoom.obs,
|
||||||
|
cursorOverImage: true.obs,
|
||||||
|
keyboardEnabled: keyboardEnabled,
|
||||||
|
remoteCursorMoved: false.obs,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
final revoke = !zoom && style != kRemoteViewStyleOriginal;
|
||||||
|
if (revoke) {
|
||||||
|
await Future.wait(cursor.cachedKeys
|
||||||
|
.map(CursorManager.instance.ensureCursorRegistered));
|
||||||
|
keyboardEnabled.value = false;
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
for (final key in cursor.cachedKeys) {
|
||||||
|
await deleteCustomCursor(key);
|
||||||
|
}
|
||||||
|
data.nativeImage.dispose();
|
||||||
|
cursor.dispose();
|
||||||
|
canvas.dispose();
|
||||||
|
expect(data.scale, closeTo(expectedScale, 1e-9));
|
||||||
|
expect(data.hotx, closeTo(data.hotxOrigin * expectedScale, 1e-9));
|
||||||
|
expect(data.hoty, closeTo(data.hotyOrigin * expectedScale, 1e-9));
|
||||||
|
expect(data.data, same(originalBytes));
|
||||||
|
if (zoom && density == 0) {
|
||||||
|
final args = registrations.single;
|
||||||
|
final rasterScale = expectedScale * (Platform.isWindows ? 1 : dpr);
|
||||||
|
final width = source.$1 * rasterScale;
|
||||||
|
final height = source.$2 * rasterScale;
|
||||||
|
expect((args['width'], args['height']),
|
||||||
|
(Platform.isLinux && height > width ? height : width, height));
|
||||||
|
expect((args['hotX'], args['hotY']),
|
||||||
|
((source.$1 ~/ 2) * rasterScale, (source.$2 ~/ 2) * rasterScale));
|
||||||
|
}
|
||||||
|
if (revoke) {
|
||||||
|
final args = registrations.last;
|
||||||
|
expect(args['name'], contains('_${kPreForbiddenCursorId}_'));
|
||||||
|
final rasterSize = 32 * (Platform.isWindows ? 1 : dpr);
|
||||||
|
expect((args['width'], args['height']), (rasterSize, rasterSize));
|
||||||
|
expect((args['hotX'], args['hotY']), (0.0, 0.0));
|
||||||
|
}
|
||||||
|
if (style == kRemoteViewStyleAdaptive && !zoom && density > 0) {
|
||||||
|
final args = registrations.first;
|
||||||
|
expect((args['width'], args['height']), ((Platform.isLinux ? 18 : 9) * dpr, 18 * dpr));
|
||||||
|
expect((args['hotX'], args['hotY']), (4 * dpr, 9 * dpr));
|
||||||
|
expect(args['imagePixelRatio'], dpr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkDprChange(
|
||||||
|
TestFlutterView view, List<Map<dynamic, dynamic>> registrations) async {
|
||||||
|
// Keep the scale above the minimum so only DPR invalidates the cache.
|
||||||
|
final data = await _data(2, 'dpr-cache');
|
||||||
|
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||||
|
final cursor = _Cursor(data, CursorTestFFI(canvas));
|
||||||
|
for (final dpr in [2.0, 1.0]) {
|
||||||
|
view.devicePixelRatio = dpr;
|
||||||
|
buildCursorOfCache(cursor, 1, data);
|
||||||
|
await deleteCustomCursor(cursor.cachedKeys.last);
|
||||||
|
}
|
||||||
|
expect(registrations, hasLength(2));
|
||||||
|
expect(registrations[0]['name'], isNot(registrations[1]['name']));
|
||||||
|
data.nativeImage.dispose();
|
||||||
|
cursor.dispose();
|
||||||
|
canvas.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPredefinedCursor(PredefinedCursor predefined,
|
||||||
|
List<Map<dynamic, dynamic>> registrations) async {
|
||||||
|
await Future.doWhile(() async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||||
|
return predefined.cache == null;
|
||||||
|
}).timeout(const Duration(seconds: 5));
|
||||||
|
final cache = predefined.cache!;
|
||||||
|
final original = img
|
||||||
|
.decodePng(base64Decode(predefined.png))!
|
||||||
|
.convert(format: img.Format.uint8, numChannels: 4);
|
||||||
|
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||||
|
final cursor = _Cursor(cache, CursorTestFFI(canvas));
|
||||||
|
buildCursorOfCache(cursor, 1, cache);
|
||||||
|
await deleteCustomCursor(cursor.cachedKeys.single);
|
||||||
|
cursor.dispose();
|
||||||
|
canvas.dispose();
|
||||||
|
|
||||||
|
final args = registrations.single;
|
||||||
|
final decoded = decodeNativeCursorRaster(args);
|
||||||
|
expect((decoded.width, decoded.height), (original.width, original.height));
|
||||||
|
expect(args['imagePixelRatio'], 1.0);
|
||||||
|
expect((args['hotX'], args['hotY']), (cache.hotxOrigin, cache.hotyOrigin));
|
||||||
|
expect(cache.image.getBytes(), original.getBytes());
|
||||||
|
// Actual bundled pixels cover transparent, opaque and translucent colors.
|
||||||
|
for (final (x, y) in [(0, 0), (1, 8), (13, 22), (16, 16)]) {
|
||||||
|
final expected = original.getPixel(x, y);
|
||||||
|
final actual = decoded.getPixel(x, y);
|
||||||
|
expect(actual.a, expected.a);
|
||||||
|
for (final (got, want) in [
|
||||||
|
(actual.r, expected.r),
|
||||||
|
(actual.g, expected.g),
|
||||||
|
(actual.b, expected.b)
|
||||||
|
]) {
|
||||||
|
expect(got, closeTo(want, 1), reason: '${predefined.id} at ($x, $y)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
flutter/test/cursor_local_display_test.dart
Normal file
252
flutter/test/cursor_local_display_test.dart
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_custom_cursor/cursor_manager.dart' as cursor_manager;
|
||||||
|
import 'package:flutter_hbb/common/shared_state.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
|
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||||
|
import 'package:flutter_hbb/models/input_model.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
const _viewport = Size(1920, 540);
|
||||||
|
const _id = 'local-display-review';
|
||||||
|
const _side = 64;
|
||||||
|
const _hotspot = Offset(8, 12);
|
||||||
|
|
||||||
|
class _Display extends Display {
|
||||||
|
_Display(this.density, double left, int w, int h) {
|
||||||
|
x = left;
|
||||||
|
width = w;
|
||||||
|
height = h;
|
||||||
|
}
|
||||||
|
final double density;
|
||||||
|
@override
|
||||||
|
double get scale => density;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Peer extends Fake implements FfiModel {
|
||||||
|
_Peer() {
|
||||||
|
pi.platform = kPeerPlatformLinux;
|
||||||
|
pi.currentDisplay = kAllDisplayValue;
|
||||||
|
pi.displays.assignAll([
|
||||||
|
_Display(2, 0, 3840, 2160),
|
||||||
|
_Display(1, 1920, 1920, 1080),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
final pi = PeerInfo();
|
||||||
|
@override
|
||||||
|
bool get isPeerLinux => true;
|
||||||
|
@override
|
||||||
|
bool get keyboard => true;
|
||||||
|
@override
|
||||||
|
bool get viewOnly => false;
|
||||||
|
@override
|
||||||
|
Rect get rect => const Rect.fromLTWH(0, 0, 3840, 1080);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Texture extends Fake implements TextureModel {
|
||||||
|
final ids = <int, RxInt>{};
|
||||||
|
@override
|
||||||
|
RxInt getTextureId(int display) =>
|
||||||
|
ids.putIfAbsent(display, () => display.obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Canvas extends CanvasModel {
|
||||||
|
_Canvas(FFI ffi, String style)
|
||||||
|
: viewStyle = ViewStyle(
|
||||||
|
style: style,
|
||||||
|
width: 1920,
|
||||||
|
height: 540,
|
||||||
|
displayWidth: 3840,
|
||||||
|
displayHeight: 1080),
|
||||||
|
super(WeakReference(ffi)) {
|
||||||
|
id = _id;
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
double get scale => 0.5;
|
||||||
|
@override
|
||||||
|
Size get size => _viewport;
|
||||||
|
@override
|
||||||
|
bool get cursorEmbedded => false;
|
||||||
|
@override
|
||||||
|
final ViewStyle viewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FFI extends Fake implements FFI {
|
||||||
|
_FFI(String style) {
|
||||||
|
canvasModel = _Canvas(this, style);
|
||||||
|
cursorModel = CursorModel(WeakReference(this));
|
||||||
|
inputModel = InputModel(WeakReference(this));
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
final sessionId = Uuid().v4obj();
|
||||||
|
@override
|
||||||
|
String get id => _id;
|
||||||
|
@override
|
||||||
|
final ffiModel = _Peer();
|
||||||
|
@override
|
||||||
|
final imageModel = CursorTestImage(useTextureRender: true);
|
||||||
|
@override
|
||||||
|
final textureModel = _Texture();
|
||||||
|
@override
|
||||||
|
late final CanvasModel canvasModel;
|
||||||
|
@override
|
||||||
|
late final CursorModel cursorModel;
|
||||||
|
@override
|
||||||
|
late final InputModel inputModel;
|
||||||
|
Offset? mappedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _shape(_FFI ffi, String id) async {
|
||||||
|
ffi.cursorModel.id = id;
|
||||||
|
await ffi.cursorModel.updateCursorData({
|
||||||
|
'id': id,
|
||||||
|
'width': '$_side',
|
||||||
|
'height': '$_side',
|
||||||
|
'scale': '2',
|
||||||
|
'hotx': '${_hotspot.dx}',
|
||||||
|
'hoty': '${_hotspot.dy}',
|
||||||
|
'colors': jsonEncode(List.generate(_side * _side * 4, (_) => 255)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _widget(_FFI ffi, double dpr) => Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(devicePixelRatio: dpr),
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ImageModel>.value(value: ffi.imageModel),
|
||||||
|
ChangeNotifierProvider<CanvasModel>.value(value: ffi.canvasModel),
|
||||||
|
ChangeNotifierProvider<CursorModel>.value(value: ffi.cursorModel),
|
||||||
|
],
|
||||||
|
child: ImagePaint(
|
||||||
|
ffi: ffi,
|
||||||
|
id: _id,
|
||||||
|
zoomCursor: true.obs,
|
||||||
|
cursorOverImage: true.obs,
|
||||||
|
keyboardEnabled: true.obs,
|
||||||
|
remoteCursorMoved: RemoteCursorMovedState.find(_id),
|
||||||
|
listenerBuilder: (child) => Listener(
|
||||||
|
child: child,
|
||||||
|
onPointerHover: (e) {
|
||||||
|
final point = ffi.inputModel.handlePointerDevicePos(
|
||||||
|
kPointerEventKindMouse,
|
||||||
|
e.position.dx,
|
||||||
|
e.position.dy,
|
||||||
|
true,
|
||||||
|
kMouseEventTypeDefault,
|
||||||
|
moveCanvas: false);
|
||||||
|
if (point != null) {
|
||||||
|
ffi.mappedPosition =
|
||||||
|
Offset(point.x.toDouble(), point.y.toDouble());
|
||||||
|
}
|
||||||
|
})))));
|
||||||
|
|
||||||
|
Future<void> _settle(WidgetTester tester, _FFI ffi) async {
|
||||||
|
await tester.pump();
|
||||||
|
for (final key in ffi.cursorModel.cachedKeys) {
|
||||||
|
await cursor_manager.CursorManager.instance.ensureCursorRegistered(key);
|
||||||
|
}
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final registrations = <Map<dynamic, dynamic>>[];
|
||||||
|
final activations = <String>[];
|
||||||
|
captureNativeCursors(registrations, activations: activations);
|
||||||
|
setUp(() => RemoteCursorMovedState.init(_id));
|
||||||
|
tearDown(() => RemoteCursorMovedState.delete(_id));
|
||||||
|
for (final (style, dpr) in [
|
||||||
|
(kRemoteViewStyleAdaptive, 1.0),
|
||||||
|
(kRemoteViewStyleCustom, 2.0),
|
||||||
|
]) {
|
||||||
|
for (final mode in ['movement', 'shape refresh', 'host position control']) {
|
||||||
|
testWidgets(
|
||||||
|
'mixed displays: $style DPR=$dpr $mode',
|
||||||
|
(tester) => tester.runAsync(
|
||||||
|
() => _checkMovement(tester, (style, dpr, mode), (registrations, activations))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkMovement(
|
||||||
|
WidgetTester tester,
|
||||||
|
(String, double, String) testCase,
|
||||||
|
(List<Map<dynamic, dynamic>>, List<String>) calls) async {
|
||||||
|
final (style, dpr, mode) = testCase;
|
||||||
|
final (registrations, activations) = calls;
|
||||||
|
tester.view.devicePixelRatio = dpr;
|
||||||
|
tester.view.physicalSize = _viewport * dpr;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final ffi = _FFI(style);
|
||||||
|
addTearDown(() => _dispose(ffi));
|
||||||
|
await _shape(ffi, '$style-$dpr-$mode-1');
|
||||||
|
await ffi.cursorModel.updateCursorPosition({'x': '100', 'y': '100'}, _id);
|
||||||
|
ffi.canvasModel.updateLocalCursor(50, 50);
|
||||||
|
await tester.pumpWidget(_widget(ffi, dpr));
|
||||||
|
await _settle(tester, ffi);
|
||||||
|
expect(registrations.last['width'], 16 * dpr);
|
||||||
|
await _moveToB(tester, ffi, (mode, activations));
|
||||||
|
final args = registrations.last;
|
||||||
|
expect((args['width'], args['height']), (32 * dpr, 32 * dpr));
|
||||||
|
expect((args['hotX'], args['hotY']), (4 * dpr, 6 * dpr));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _moveToB(WidgetTester tester, _FFI ffi, (String, List<String>) testCase) async {
|
||||||
|
final (mode, activations) = testCase;
|
||||||
|
final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
|
||||||
|
await mouse.addPointer(location: const Offset(50, 50));
|
||||||
|
await _settle(tester, ffi);
|
||||||
|
final before = activations.length;
|
||||||
|
for (final x in [60.0, 70.0, 80.0]) {
|
||||||
|
await mouse.moveTo(Offset(x, 50));
|
||||||
|
await _settle(tester, ffi);
|
||||||
|
expect(activations.length, before);
|
||||||
|
}
|
||||||
|
await mouse.moveTo(
|
||||||
|
Offset(1100 + CanvasModel.leftToEdge, 50 + CanvasModel.topToEdge));
|
||||||
|
await _settle(tester, ffi);
|
||||||
|
expect(activations.length, before + 1);
|
||||||
|
expect(ffi.mappedPosition, const Offset(2200, 100));
|
||||||
|
expect(ffi.cursorModel.offset, const Offset(100, 100));
|
||||||
|
if (mode == 'shape refresh') {
|
||||||
|
await _shape(ffi, '$mode-2');
|
||||||
|
} else if (mode == 'host position control') {
|
||||||
|
await ffi.cursorModel.updateCursorPosition({'x': '2200', 'y': '100'}, _id);
|
||||||
|
ffi.canvasModel.updateLocalCursor(1100, 50);
|
||||||
|
}
|
||||||
|
await _settle(tester, ffi);
|
||||||
|
final after = activations.length;
|
||||||
|
expect(after, before + (mode == 'shape refresh' ? 2 : 1));
|
||||||
|
for (final x in [1110.0, 1120.0, 1130.0]) {
|
||||||
|
await mouse.moveTo(Offset(x, 50));
|
||||||
|
await _settle(tester, ffi);
|
||||||
|
expect(activations.length, after);
|
||||||
|
}
|
||||||
|
final displayB = tester.widgetList<Positioned>(find.byType(Positioned)).last;
|
||||||
|
expect(displayB.width! / 1920, 0.5);
|
||||||
|
await mouse.removePointer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dispose(_FFI ffi) async {
|
||||||
|
for (final key in ffi.cursorModel.cachedKeys) {
|
||||||
|
await deleteCustomCursor(key);
|
||||||
|
}
|
||||||
|
ffi.cursorModel.disposeImages();
|
||||||
|
ffi.cursorModel.dispose();
|
||||||
|
ffi.canvasModel.dispose();
|
||||||
|
ffi.imageModel.dispose();
|
||||||
|
// Relative mode is never started and this fixture has no Rust session.
|
||||||
|
ffi.inputModel.disposeSideButtonTracking();
|
||||||
|
}
|
||||||
118
flutter/test/cursor_native_alpha_test.dart
Normal file
118
flutter/test/cursor_native_alpha_test.dart
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/native/custom_cursor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
const _side = 32;
|
||||||
|
const _hotspot = Offset(7, 9);
|
||||||
|
const _colors = [
|
||||||
|
[64, 32, 16, 128],
|
||||||
|
[240, 100, 20, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
[0, 0, 0, 128],
|
||||||
|
];
|
||||||
|
final _stripeWidth = _side ~/ _colors.length;
|
||||||
|
|
||||||
|
class _Canvas extends Fake implements CanvasModel {
|
||||||
|
@override
|
||||||
|
final viewStyle = ViewStyle(
|
||||||
|
style: kRemoteViewStyleAdaptive,
|
||||||
|
width: 200,
|
||||||
|
height: 160,
|
||||||
|
displayWidth: 200,
|
||||||
|
displayHeight: 160);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final view = binding.platformDispatcher.views.single;
|
||||||
|
final registrations = <Map<dynamic, dynamic>>[];
|
||||||
|
captureNativeCursors(registrations);
|
||||||
|
tearDown(view.resetDevicePixelRatio);
|
||||||
|
for (final testCase in <(String, String?, List<int>)>[
|
||||||
|
(kPeerPlatformMacOS, null, [64, 32, 16, 128]),
|
||||||
|
(kPeerPlatformWindows, '0', [64, 32, 16, 128]),
|
||||||
|
(kPeerPlatformLinux, '0', [32, 16, 8, 128]),
|
||||||
|
]) {
|
||||||
|
for (final dpr in [1.0, 2.0]) {
|
||||||
|
test(
|
||||||
|
'native ${testCase.$1} scale=${testCase.$2} DPR=$dpr preserves alpha',
|
||||||
|
() {
|
||||||
|
view.devicePixelRatio = dpr;
|
||||||
|
return _checkCursor(testCase, dpr, registrations);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkCursor((String, String?, List<int>) testCase, double dpr,
|
||||||
|
List<Map<dynamic, dynamic>> registrations) async {
|
||||||
|
final (platform, density, color) = testCase;
|
||||||
|
final ffi = CursorTestFFI(_Canvas())..ffiModel.pi.platform = platform;
|
||||||
|
final cursor = CursorModel(WeakReference(ffi))..id = '$testCase-$dpr';
|
||||||
|
addTearDown(() {
|
||||||
|
cursor.disposeImages();
|
||||||
|
cursor.dispose();
|
||||||
|
});
|
||||||
|
final palette = [color, ..._colors.skip(1)];
|
||||||
|
final rgba = [
|
||||||
|
for (var y = 0; y < _side; y++)
|
||||||
|
for (var x = 0; x < _side; x++) ...palette[x ~/ _stripeWidth]
|
||||||
|
];
|
||||||
|
await cursor.updateCursorData({
|
||||||
|
'id': '$testCase-$dpr',
|
||||||
|
'width': '$_side',
|
||||||
|
'height': '$_side',
|
||||||
|
'hotx': '${_hotspot.dx}',
|
||||||
|
'hoty': '${_hotspot.dy}',
|
||||||
|
if (density != null) 'scale': density,
|
||||||
|
'colors': jsonEncode(rgba),
|
||||||
|
});
|
||||||
|
final cache = cursor.cache!;
|
||||||
|
buildCursorOfCache(cursor, Platform.isWindows ? dpr : 1, cache);
|
||||||
|
await deleteCustomCursor(cursor.cachedKeys.single);
|
||||||
|
expect(cache.image.getBytes(), rgba); // Keep the original byte-cache source.
|
||||||
|
_checkRegistration(registrations.single, dpr);
|
||||||
|
// The same ui.Image also supplies the painted remote cursor.
|
||||||
|
final straight = await cache.nativeImage
|
||||||
|
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||||
|
_checkColors(img.Image.fromBytes(
|
||||||
|
width: _side,
|
||||||
|
height: _side,
|
||||||
|
bytes: straight!.buffer,
|
||||||
|
bytesOffset: straight.offsetInBytes,
|
||||||
|
order: img.ChannelOrder.rgba));
|
||||||
|
// Real sessions own FFI strongly throughout asynchronous cursor decoding.
|
||||||
|
expect(cursor.parent.target, same(ffi));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkRegistration(Map<dynamic, dynamic> args, double dpr) {
|
||||||
|
final decoded = decodeNativeCursorRaster(args);
|
||||||
|
expect((decoded.width, decoded.height), (_side * dpr, _side * dpr));
|
||||||
|
expect((args['hotX'], args['hotY']), (_hotspot.dx * dpr, _hotspot.dy * dpr));
|
||||||
|
_checkColors(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkColors(img.Image bitmap) {
|
||||||
|
for (var stripe = 0; stripe < _colors.length; stripe++) {
|
||||||
|
final pixel = bitmap.getPixel(
|
||||||
|
((stripe + 0.5) * bitmap.width / _colors.length).floor(),
|
||||||
|
bitmap.height ~/ 2);
|
||||||
|
final expected = _colors[stripe];
|
||||||
|
expect(pixel.a, expected.last);
|
||||||
|
for (final (actual, wanted) in [
|
||||||
|
(pixel.r, expected[0]),
|
||||||
|
(pixel.g, expected[1]),
|
||||||
|
(pixel.b, expected[2])
|
||||||
|
]) {
|
||||||
|
expect(actual, closeTo(wanted, 1), reason: 'RGBA stripe $stripe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
287
flutter/test/cursor_paint_scale_test.dart
Normal file
287
flutter/test/cursor_paint_scale_test.dart
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/utils/image.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
const _hotspot = Offset(4, 9);
|
||||||
|
const _remotePosition = Offset(100.25, 80.75);
|
||||||
|
const _canvasOffset = Offset(15.125, -10.25);
|
||||||
|
const _viewport = Size(200, 160);
|
||||||
|
|
||||||
|
class _CursorModel extends ChangeNotifier implements CursorModel {
|
||||||
|
_CursorModel(this.image, this.position, this.density, {this.hotspot = _hotspot});
|
||||||
|
|
||||||
|
final Offset position;
|
||||||
|
final double density;
|
||||||
|
final Offset hotspot;
|
||||||
|
@override
|
||||||
|
CursorData get cache => _Density(density);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ui.Image image;
|
||||||
|
@override
|
||||||
|
double get hotx => hotspot.dx;
|
||||||
|
@override
|
||||||
|
double get hoty => hotspot.dy;
|
||||||
|
@override
|
||||||
|
double get x => position.dx;
|
||||||
|
@override
|
||||||
|
double get y => position.dy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Density extends Fake implements CursorData {
|
||||||
|
_Density(this.pixelRatio);
|
||||||
|
@override
|
||||||
|
final double pixelRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageModel extends ChangeNotifier implements ImageModel {
|
||||||
|
_ImageModel(this.useTextureRender);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final bool useTextureRender;
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Display extends Display {
|
||||||
|
_Display(this.scale, double left) {
|
||||||
|
x = left;
|
||||||
|
width = 3840;
|
||||||
|
height = 2160;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
final double scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Peer extends Fake implements FfiModel {
|
||||||
|
@override
|
||||||
|
final pi = PeerInfo()
|
||||||
|
..displays.add(Display()
|
||||||
|
..width = _viewport.width.toInt()
|
||||||
|
..height = _viewport.height.toInt());
|
||||||
|
@override
|
||||||
|
bool isPeerLinux = false;
|
||||||
|
@override
|
||||||
|
Rect rect = Offset.zero & _viewport;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FFI extends Fake implements FFI {
|
||||||
|
_FFI(bool useTexture) : imageModel = _ImageModel(useTexture);
|
||||||
|
|
||||||
|
@override
|
||||||
|
final ImageModel imageModel;
|
||||||
|
@override
|
||||||
|
final _Peer ffiModel = _Peer();
|
||||||
|
@override
|
||||||
|
final textureModel = CursorTestTexture();
|
||||||
|
@override
|
||||||
|
late CanvasModel canvasModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CanvasModel extends ChangeNotifier implements CanvasModel {
|
||||||
|
_CanvasModel(String style, this.scale, bool useTexture)
|
||||||
|
: _ffi = _FFI(useTexture),
|
||||||
|
viewStyle = ViewStyle(
|
||||||
|
style: style,
|
||||||
|
width: _viewport.width,
|
||||||
|
height: _viewport.height,
|
||||||
|
displayWidth: _viewport.width.toInt(),
|
||||||
|
displayHeight: _viewport.height.toInt(),
|
||||||
|
) {
|
||||||
|
_ffi.canvasModel = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _FFI _ffi;
|
||||||
|
@override
|
||||||
|
WeakReference<FFI> get parent => WeakReference(_ffi);
|
||||||
|
@override
|
||||||
|
final imageOverflow = false.obs;
|
||||||
|
@override
|
||||||
|
final ViewStyle viewStyle;
|
||||||
|
@override
|
||||||
|
double get x => _canvasOffset.dx;
|
||||||
|
@override
|
||||||
|
double get y => _canvasOffset.dy;
|
||||||
|
@override
|
||||||
|
final double scale;
|
||||||
|
@override
|
||||||
|
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||||
|
@override
|
||||||
|
Size get size => _viewport;
|
||||||
|
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScrollbarCanvasModel extends _CanvasModel {
|
||||||
|
_ScrollbarCanvasModel(String style, {Size? frame, double scale = 2})
|
||||||
|
: super(style, scale, true) {
|
||||||
|
imageOverflow.value = true;
|
||||||
|
if (frame != null) _ffi.ffiModel.rect = Offset.zero & frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollStyle get scrollStyle => ScrollStyle.scrollbar;
|
||||||
|
@override
|
||||||
|
double get scrollX => _ffi.ffiModel.rect.width * scale > size.width ? 0.1 : 0;
|
||||||
|
@override
|
||||||
|
double get scrollY => _ffi.ffiModel.rect.height * scale > size.height ? 0.2 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ImagePainter> _paintCursor(WidgetTester tester, CanvasModel canvas,
|
||||||
|
{double dpr = 2, bool zoom = true, (int, int) source = (48, 64),
|
||||||
|
Offset position = _remotePosition, double density = 0,
|
||||||
|
Offset hotspot = _hotspot}) async {
|
||||||
|
final image = (await tester
|
||||||
|
.runAsync(() => createTestImage(width: source.$1, height: source.$2)))!;
|
||||||
|
addTearDown(image.dispose);
|
||||||
|
tester.view.devicePixelRatio = dpr;
|
||||||
|
tester.view.physicalSize = _viewport * dpr;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(devicePixelRatio: dpr),
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ImageModel>.value(value: canvas.parent.target!.imageModel),
|
||||||
|
ChangeNotifierProvider<CursorModel>(create: (_) => _CursorModel(image, position, density, hotspot: hotspot)),
|
||||||
|
ChangeNotifierProvider<CanvasModel>(create: (_) => canvas),
|
||||||
|
],
|
||||||
|
child: Stack(fit: StackFit.expand, children: [
|
||||||
|
// Measure the video's origin instead of copying its rounding policy.
|
||||||
|
if (canvas.parent.target!.imageModel.useTextureRender &&
|
||||||
|
canvas.scrollStyle == ScrollStyle.scrollauto)
|
||||||
|
ImagePaint(ffi: canvas.parent.target!, id: 'cursor-test',
|
||||||
|
zoomCursor: zoom.obs, cursorOverImage: false.obs,
|
||||||
|
keyboardEnabled: true.obs, remoteCursorMoved: false.obs),
|
||||||
|
CursorPaint(id: 'cursor-test', zoomCursor: zoom.obs),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)));
|
||||||
|
final painter = tester.widget<CustomPaint>(find.byType(CustomPaint)).painter!
|
||||||
|
as ImagePainter;
|
||||||
|
expect(painter.image, same(image));
|
||||||
|
return painter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _linuxDisplayTests() {
|
||||||
|
for (final (texture, displayScale, allDisplays) in [
|
||||||
|
(false, 2.0, false), (true, 2.0, false), (true, 2.0, true),
|
||||||
|
(false, 1.0, false), (true, 1.0, false),
|
||||||
|
]) {
|
||||||
|
testWidgets('Linux scale=$displayScale texture=$texture all=$allDisplays',
|
||||||
|
(tester) async {
|
||||||
|
final canvas = _CanvasModel(kRemoteViewStyleCustom, 2, texture);
|
||||||
|
final peer = canvas._ffi.ffiModel;
|
||||||
|
peer.isPeerLinux = true;
|
||||||
|
// The first output's physical extent overlaps the second in logical
|
||||||
|
// coordinates. Selection must use logical extents and the union origin.
|
||||||
|
peer.pi.displays.assignAll([
|
||||||
|
if (displayScale > 1) _Display(4, -960), _Display(displayScale, 0),
|
||||||
|
]);
|
||||||
|
peer.pi.currentDisplay = allDisplays ? kAllDisplayValue
|
||||||
|
: peer.pi.displays.length - 1;
|
||||||
|
peer.rect = Rect.fromLTWH(allDisplays ? -960 : 0, 0, 3840, 2160);
|
||||||
|
final painter = await _paintCursor(tester, canvas,
|
||||||
|
dpr: 1, zoom: false, source: (64, 64), density: 2,
|
||||||
|
position: allDisplays ? _remotePosition + const Offset(960, 0)
|
||||||
|
: _remotePosition);
|
||||||
|
final pixelScale = 2 / displayScale;
|
||||||
|
expect(painter.scale, pixelScale);
|
||||||
|
final origin = texture ? tester.getTopLeft(find.byType(Texture).first)
|
||||||
|
: Offset((_canvasOffset.dx / pixelScale).toInt() * pixelScale,
|
||||||
|
(_canvasOffset.dy / pixelScale).toInt() * pixelScale);
|
||||||
|
final position = allDisplays
|
||||||
|
? _remotePosition + const Offset(960, 0) : _remotePosition;
|
||||||
|
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale,
|
||||||
|
position * 2 + origin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
_linuxDisplayTests();
|
||||||
|
final minimumScale = Platform.isWindows ? 1 / 3 : 2 / 3;
|
||||||
|
for (final (style, zoom, dpr, source, canvasScale, scale, texture, density) in [
|
||||||
|
(kRemoteViewStyleAdaptive, false, 2.0, (48, 64), 0.375, 0.375, true, 0.0),
|
||||||
|
(kRemoteViewStyleOriginal, false, 2.0, (48, 64), 0.5, 0.5, true, 0.0),
|
||||||
|
(kRemoteViewStyleCustom, false, 2.0, (48, 64), 0.25, 0.25, false, 0.0),
|
||||||
|
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 2.0, 2.0, false, 0.0),
|
||||||
|
(kRemoteViewStyleAdaptive, false, 2.25, (48, 48), 0.375, 0.375, false, 0.0),
|
||||||
|
(kRemoteViewStyleAdaptive, true, 2.0, (9, 18), 0.1, minimumScale, true, 0.0),
|
||||||
|
(kRemoteViewStyleAdaptive, true, 1.0, (64, 4), 0.5, 0.5, true, 0.0),
|
||||||
|
(kRemoteViewStyleCustom, true, 2.0, (4, 64), 0.5, 0.5, true, 0.0),
|
||||||
|
(kRemoteViewStyleAdaptive, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||||
|
(kRemoteViewStyleAdaptive, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||||
|
(kRemoteViewStyleCustom, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||||
|
(kRemoteViewStyleCustom, true, 2.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||||
|
(kRemoteViewStyleCustom, true, 2.0, (8, 10), 1.0, 1.2, true, 2.0),
|
||||||
|
(kRemoteViewStyleOriginal, true, 1.0, (48, 64), 0.05, 0.1875, true, 2.0),
|
||||||
|
(kRemoteViewStyleOriginal, true, 2.0, (48, 64), 0.05,
|
||||||
|
Platform.isWindows ? 0.09375 : 0.1875, true, 2.0),
|
||||||
|
]) {
|
||||||
|
testWidgets(
|
||||||
|
'$style zoom=$zoom dpr=$dpr source=$source texture=$texture density=$density keeps remote geometry',
|
||||||
|
(tester) async {
|
||||||
|
final sourceHotspot = source.$1 <= _hotspot.dx || source.$2 <= _hotspot.dy
|
||||||
|
? Offset(source.$1 / 2, source.$2 / 2) : _hotspot;
|
||||||
|
final painter = await _paintCursor(
|
||||||
|
tester, _CanvasModel(style, canvasScale, texture),
|
||||||
|
dpr: dpr, zoom: zoom, source: source, density: density,
|
||||||
|
hotspot: sourceHotspot);
|
||||||
|
expect(painter.scale, scale);
|
||||||
|
var imageOrigin = texture
|
||||||
|
? tester.getTopLeft(find.byType(Texture).first) : _canvasOffset;
|
||||||
|
if (!texture) {
|
||||||
|
final background = CursorTestDraw();
|
||||||
|
ImagePainter(
|
||||||
|
image: painter.image,
|
||||||
|
x: _canvasOffset.dx / canvasScale,
|
||||||
|
y: _canvasOffset.dy / canvasScale,
|
||||||
|
scale: canvasScale,
|
||||||
|
).paint(background, _viewport);
|
||||||
|
imageOrigin = background.position!;
|
||||||
|
}
|
||||||
|
final target = _remotePosition * canvasScale + imageOrigin;
|
||||||
|
final hotspot = (Offset(painter.x, painter.y) + sourceHotspot) * scale;
|
||||||
|
expect(hotspot.dx, closeTo(target.dx, 1e-9));
|
||||||
|
expect(hotspot.dy, closeTo(target.dy, 1e-9));
|
||||||
|
final canvas = CursorTestDraw();
|
||||||
|
painter.paint(canvas, _viewport);
|
||||||
|
final position = canvas.position! + sourceHotspot * canvas.factor;
|
||||||
|
expect(position.dx, closeTo(target.dx, 1e-9));
|
||||||
|
expect(position.dy, closeTo(target.dy, 1e-9));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (final style in [kRemoteViewStyleOriginal, kRemoteViewStyleCustom]) {
|
||||||
|
for (final (frame, scale, offset) in [
|
||||||
|
(_viewport, 2.0, const Offset(-40, -64)),
|
||||||
|
(const Size(199, 320), 1.0, const Offset(0, -64)),
|
||||||
|
(const Size(198, 320), 1.0, const Offset(1, -64)),
|
||||||
|
(const Size(400, 159), 1.0, const Offset(-40, 0)),
|
||||||
|
(const Size(400, 158), 1.0, const Offset(-40, 1)),
|
||||||
|
]) {
|
||||||
|
testWidgets('$style frame=$frame painted cursor follows scrollbar layout',
|
||||||
|
(tester) async {
|
||||||
|
final painter = await _paintCursor(
|
||||||
|
tester, _ScrollbarCanvasModel(style, frame: frame, scale: scale));
|
||||||
|
final target = _remotePosition * scale + offset;
|
||||||
|
expect((Offset(painter.x, painter.y) + _hotspot) * painter.scale, target);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
flutter/test/cursor_scroll_edge_test.dart
Normal file
294
flutter/test/cursor_scroll_edge_test.dart
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_hbb/utils/image.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:vector_math/vector_math.dart' show Vector2;
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
const _viewport = Size(200, 160);
|
||||||
|
const _hotspot = Offset(4, 9);
|
||||||
|
const _scrollDeltas = [80.0, -20.0, 1000.0, 1000.0, -1000.0, -1000.0, 80.0, 0.0];
|
||||||
|
const _fittingViewports = [
|
||||||
|
Size(500, 160), // Fit horizontally while vertical scrolling remains.
|
||||||
|
Size(200, 400), // Fit vertically while horizontal scrolling remains.
|
||||||
|
Size(400, 160), // Exact fit also detaches the controller.
|
||||||
|
Size(200, 320),
|
||||||
|
];
|
||||||
|
|
||||||
|
class _Peer extends Fake implements FfiModel {
|
||||||
|
_Peer(ui.Image frame)
|
||||||
|
: rect = Rect.fromLTWH(
|
||||||
|
0, 0, frame.width.toDouble(), frame.height.toDouble()) {
|
||||||
|
pi.displays.add(Display()
|
||||||
|
..width = frame.width
|
||||||
|
..height = frame.height);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
final pi = PeerInfo();
|
||||||
|
@override
|
||||||
|
final Rect rect;
|
||||||
|
@override
|
||||||
|
bool get isPeerLinux => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Image extends ImageModel {
|
||||||
|
_Image(FFI ffi, this.image, this.useTextureRender)
|
||||||
|
: super(WeakReference(ffi));
|
||||||
|
@override
|
||||||
|
final ui.Image image;
|
||||||
|
@override
|
||||||
|
final bool useTextureRender;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScrollCanvas extends CanvasModel {
|
||||||
|
_ScrollCanvas(FFI ffi, String style, this.scrollStyle)
|
||||||
|
: viewStyle = ViewStyle(
|
||||||
|
style: style,
|
||||||
|
width: _viewport.width,
|
||||||
|
height: _viewport.height,
|
||||||
|
displayWidth: ffi.ffiModel.rect!.width.toInt(),
|
||||||
|
displayHeight: ffi.ffiModel.rect!.height.toInt()),
|
||||||
|
super(WeakReference(ffi)) {
|
||||||
|
resetOffset();
|
||||||
|
imageOverflow.value = true;
|
||||||
|
}
|
||||||
|
Size viewport = _viewport;
|
||||||
|
double imageScale = 1;
|
||||||
|
@override
|
||||||
|
Size get size => viewport;
|
||||||
|
@override
|
||||||
|
double get scale => imageScale;
|
||||||
|
@override
|
||||||
|
double get x => (size.width - getDisplayWidth() * scale) / 2;
|
||||||
|
@override
|
||||||
|
double get y => (size.height - getDisplayHeight() * scale) / 2;
|
||||||
|
@override
|
||||||
|
final ScrollStyle scrollStyle;
|
||||||
|
@override
|
||||||
|
final ViewStyle viewStyle;
|
||||||
|
|
||||||
|
void relayout(Size viewport, double dpr) {
|
||||||
|
this.viewport = viewport;
|
||||||
|
imageScale = 1 / dpr;
|
||||||
|
imageOverflow.value = viewport.width < getDisplayWidth() * scale ||
|
||||||
|
viewport.height < getDisplayHeight() * scale;
|
||||||
|
setScrollPercent(0, 0);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Cursor extends CursorModel {
|
||||||
|
_Cursor(FFI ffi, this.image, this.position) : super(WeakReference(ffi));
|
||||||
|
final Offset position;
|
||||||
|
@override
|
||||||
|
final ui.Image image;
|
||||||
|
@override
|
||||||
|
double get hotx => _hotspot.dx;
|
||||||
|
@override
|
||||||
|
double get hoty => _hotspot.dy;
|
||||||
|
@override
|
||||||
|
double get x => position.dx;
|
||||||
|
@override
|
||||||
|
double get y => position.dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FFI extends Fake implements FFI {
|
||||||
|
_FFI(ui.Image frame, ui.Image cursor,
|
||||||
|
{required bool texture, required (String, ScrollStyle) style, required Offset position})
|
||||||
|
: ffiModel = _Peer(frame) {
|
||||||
|
imageModel = _Image(this, frame, texture);
|
||||||
|
canvasModel = _ScrollCanvas(this, style.$1, style.$2);
|
||||||
|
cursorModel = _Cursor(this, cursor, position);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
final sessionId = Uuid().v4obj();
|
||||||
|
@override
|
||||||
|
final _Peer ffiModel;
|
||||||
|
@override
|
||||||
|
late final ImageModel imageModel;
|
||||||
|
@override
|
||||||
|
late final CanvasModel canvasModel;
|
||||||
|
@override
|
||||||
|
late final CursorModel cursorModel;
|
||||||
|
@override
|
||||||
|
final textureModel = CursorTestTexture();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final style in [(kRemoteViewStyleOriginal, ScrollStyle.scrolledge),
|
||||||
|
(kRemoteViewStyleOriginal, ScrollStyle.scrollbar),
|
||||||
|
(kRemoteViewStyleCustom, ScrollStyle.scrolledge),
|
||||||
|
(kRemoteViewStyleCustom, ScrollStyle.scrollbar)]) {
|
||||||
|
for (final texture in [false, true]) {
|
||||||
|
for (final (frame, dpr) in [
|
||||||
|
(const Size(199, 320), 1.0),
|
||||||
|
(const Size(198, 320), 2.0),
|
||||||
|
(const Size(400, 159), 2.0),
|
||||||
|
(const Size(400, 158), 1.0),
|
||||||
|
]) {
|
||||||
|
testWidgets(
|
||||||
|
'Scroll $style texture=$texture frame=$frame DPR=$dpr',
|
||||||
|
(tester) => tester
|
||||||
|
.runAsync(() => _check(tester, (style, texture, frame, dpr))));
|
||||||
|
}
|
||||||
|
for (final refreshBeforeLayout in [true, false]) {
|
||||||
|
testWidgets(
|
||||||
|
'Scroll relayout $style texture=$texture early=$refreshBeforeLayout',
|
||||||
|
(tester) => tester.runAsync(() =>
|
||||||
|
_checkRelayout(tester, (style, texture, refreshBeforeLayout))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _check(
|
||||||
|
WidgetTester tester, ((String, ScrollStyle), bool, Size, double) testCase) async {
|
||||||
|
final (style, texture, frame, dpr) = testCase;
|
||||||
|
tester.view.devicePixelRatio = dpr;
|
||||||
|
tester.view.physicalSize = _viewport * dpr;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final vertical = frame.height > _viewport.height;
|
||||||
|
final pointer =
|
||||||
|
vertical ? const Offset(50.25, 200.75) : const Offset(240.25, 50.75);
|
||||||
|
final video = await createTestImage(
|
||||||
|
width: frame.width.toInt(), height: frame.height.toInt());
|
||||||
|
final cursor = await createTestImage(width: 48, height: 64);
|
||||||
|
final ffi =
|
||||||
|
_FFI(video, cursor, texture: texture, style: style, position: pointer);
|
||||||
|
addTearDown(() {
|
||||||
|
ffi.canvasModel.scrollHorizontal.dispose();
|
||||||
|
ffi.canvasModel.scrollVertical.dispose();
|
||||||
|
ffi.canvasModel.dispose();
|
||||||
|
ffi.imageModel.dispose();
|
||||||
|
ffi.cursorModel.dispose();
|
||||||
|
video.dispose();
|
||||||
|
cursor.dispose();
|
||||||
|
});
|
||||||
|
await _mount(tester, ffi, dpr);
|
||||||
|
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
|
||||||
|
final scrolling = vertical
|
||||||
|
? ffi.canvasModel.scrollVertical
|
||||||
|
: ffi.canvasModel.scrollHorizontal;
|
||||||
|
for (final distance in _scrollDeltas) {
|
||||||
|
// Drive the real scroll controllers; canvas pan offsets are not the video origin.
|
||||||
|
ffi.canvasModel.performEdgeScroll(
|
||||||
|
vertical ? Vector2(0, distance) : Vector2(distance, 0));
|
||||||
|
expect(vertical ? ffi.canvasModel.scrollY : ffi.canvasModel.scrollX,
|
||||||
|
closeTo(scrolling.offset / (vertical ? frame.height : frame.width), 1e-9));
|
||||||
|
await tester.pump();
|
||||||
|
_expectAlignment(tester, ffi, videoWidget);
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkRelayout(
|
||||||
|
WidgetTester tester, ((String, ScrollStyle), bool, bool) testCase) async {
|
||||||
|
final (style, texture, refreshBeforeLayout) = testCase;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = _viewport;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
final video = await createTestImage(width: 400, height: 320);
|
||||||
|
final cursor = await createTestImage(width: 48, height: 64);
|
||||||
|
final ffi = _FFI(video, cursor,
|
||||||
|
texture: texture, style: style, position: const Offset(150.25, 120.75));
|
||||||
|
final canvas = ffi.canvasModel as _ScrollCanvas;
|
||||||
|
addTearDown(() {
|
||||||
|
canvas.scrollHorizontal.dispose();
|
||||||
|
canvas.scrollVertical.dispose();
|
||||||
|
canvas.dispose();
|
||||||
|
ffi.imageModel.dispose();
|
||||||
|
ffi.cursorModel.dispose();
|
||||||
|
video.dispose();
|
||||||
|
cursor.dispose();
|
||||||
|
});
|
||||||
|
await _mount(tester, ffi, 1);
|
||||||
|
final videoWidget = texture ? find.byType(Texture) : _paintOf(video);
|
||||||
|
for (final (viewport, dpr) in [
|
||||||
|
for (final viewport in _fittingViewports)
|
||||||
|
...[(viewport, 1.0), (_viewport, 1.0)],
|
||||||
|
(const Size(160, 120), 2.0),
|
||||||
|
(const Size(195, 155), 2.0), // Clamp both existing scroll positions.
|
||||||
|
(const Size(240, 155), 2.0), // Detach the horizontal scroll controller.
|
||||||
|
(const Size(240, 200), 2.0), // No scrolling remains.
|
||||||
|
(_viewport, 1.0),
|
||||||
|
]) {
|
||||||
|
canvas.performEdgeScroll(Vector2(20, 20));
|
||||||
|
await tester.pump();
|
||||||
|
_expectAlignment(tester, ffi, videoWidget);
|
||||||
|
tester.view.devicePixelRatio = dpr;
|
||||||
|
tester.view.physicalSize = viewport * dpr;
|
||||||
|
canvas.relayout(viewport, dpr);
|
||||||
|
// A settings refresh may run before layout or after the first cursor build.
|
||||||
|
if (refreshBeforeLayout) {
|
||||||
|
canvas.updateScrollPercent();
|
||||||
|
} else {
|
||||||
|
tester.binding.addPostFrameCallback((_) => canvas.updateScrollPercent());
|
||||||
|
}
|
||||||
|
await _mount(tester, ffi, dpr);
|
||||||
|
await tester.pumpAndSettle(const Duration(milliseconds: 16),
|
||||||
|
EnginePhase.sendSemanticsUpdate, const Duration(seconds: 1));
|
||||||
|
_expectAlignment(tester, ffi, videoWidget);
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _expectAlignment(WidgetTester tester, FFI ffi, Finder videoWidget) {
|
||||||
|
final cursor = ffi.cursorModel;
|
||||||
|
final cursorWidget = _paintOf(cursor.image!);
|
||||||
|
final painter =
|
||||||
|
tester.widget<CustomPaint>(cursorWidget).painter! as ImagePainter;
|
||||||
|
final output = CursorTestDraw();
|
||||||
|
painter.paint(output, ffi.canvasModel.size);
|
||||||
|
final hotspot = tester.getTopLeft(cursorWidget) +
|
||||||
|
output.position! +
|
||||||
|
_hotspot * output.factor;
|
||||||
|
var videoOrigin = tester.getTopLeft(videoWidget);
|
||||||
|
final video = tester.widget(videoWidget);
|
||||||
|
if (video is CustomPaint) {
|
||||||
|
final drawnVideo = CursorTestDraw();
|
||||||
|
video.painter!.paint(drawnVideo, ffi.canvasModel.size);
|
||||||
|
videoOrigin += drawnVideo.position!;
|
||||||
|
}
|
||||||
|
final target =
|
||||||
|
videoOrigin + Offset(cursor.x, cursor.y) * ffi.canvasModel.scale;
|
||||||
|
expect(hotspot.dx, closeTo(target.dx, 1e-9));
|
||||||
|
expect(hotspot.dy, closeTo(target.dy, 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder _paintOf(ui.Image image) => find.byWidgetPredicate((widget) =>
|
||||||
|
widget is CustomPaint &&
|
||||||
|
widget.painter is ImagePainter &&
|
||||||
|
identical((widget.painter as ImagePainter).image, image));
|
||||||
|
|
||||||
|
Future<void> _mount(WidgetTester tester, FFI ffi, double dpr) =>
|
||||||
|
tester.pumpWidget(Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: MediaQuery(
|
||||||
|
data: MediaQueryData(devicePixelRatio: dpr),
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<ImageModel>.value(
|
||||||
|
value: ffi.imageModel),
|
||||||
|
ChangeNotifierProvider<CanvasModel>.value(
|
||||||
|
value: ffi.canvasModel),
|
||||||
|
ChangeNotifierProvider<CursorModel>.value(
|
||||||
|
value: ffi.cursorModel),
|
||||||
|
],
|
||||||
|
child: Stack(fit: StackFit.expand, children: [
|
||||||
|
ImagePaint(
|
||||||
|
ffi: ffi,
|
||||||
|
id: 'scroll-edge-test',
|
||||||
|
zoomCursor: false.obs,
|
||||||
|
cursorOverImage: false.obs,
|
||||||
|
keyboardEnabled: true.obs,
|
||||||
|
remoteCursorMoved: false.obs),
|
||||||
|
CursorPaint(id: 'scroll-edge-test', zoomCursor: false.obs),
|
||||||
|
])))));
|
||||||
127
flutter/test/cursor_test_utils.dart
Normal file
127
flutter/test/cursor_test_utils.dart
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hbb/common.dart' as common;
|
||||||
|
import 'package:flutter_hbb/models/desktop_render_texture.dart';
|
||||||
|
import 'package:flutter_hbb/models/input_model.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
class CursorTestImage extends ChangeNotifier implements ImageModel {
|
||||||
|
CursorTestImage({this.useTextureRender = false});
|
||||||
|
@override
|
||||||
|
final bool useTextureRender;
|
||||||
|
@override
|
||||||
|
ui.Image? get image => null;
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CursorTestCanvas extends ChangeNotifier implements CanvasModel {
|
||||||
|
CursorTestCanvas(this.devicePixelRatio,
|
||||||
|
{required this.style, required this.scale});
|
||||||
|
|
||||||
|
final String style;
|
||||||
|
@override
|
||||||
|
final double devicePixelRatio;
|
||||||
|
@override
|
||||||
|
final imageOverflow = false.obs;
|
||||||
|
@override
|
||||||
|
late final viewStyle = ViewStyle(
|
||||||
|
style: style,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
displayWidth: 400,
|
||||||
|
displayHeight: 320,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
bool get cursorEmbedded => false;
|
||||||
|
@override
|
||||||
|
ScrollStyle get scrollStyle => ScrollStyle.scrollauto;
|
||||||
|
@override
|
||||||
|
Size get size => const Size(200, 160);
|
||||||
|
@override
|
||||||
|
final double scale;
|
||||||
|
@override
|
||||||
|
double get x => 0;
|
||||||
|
@override
|
||||||
|
double get y => 0;
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Input extends Fake implements InputModel {
|
||||||
|
@override
|
||||||
|
final relativeMouseMode = false.obs;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CursorTestPeer extends Fake implements FfiModel {
|
||||||
|
@override
|
||||||
|
final pi = PeerInfo();
|
||||||
|
@override
|
||||||
|
bool isPeerLinux = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CursorTestFFI extends Fake implements FFI {
|
||||||
|
CursorTestFFI(this.canvasModel);
|
||||||
|
@override
|
||||||
|
final CanvasModel canvasModel;
|
||||||
|
@override
|
||||||
|
final inputModel = _Input();
|
||||||
|
@override
|
||||||
|
final CursorTestPeer ffiModel = CursorTestPeer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void captureNativeCursors(List<Map<dynamic, dynamic>> registrations,
|
||||||
|
{List<String>? activations}) {
|
||||||
|
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final channel = common.isWindows
|
||||||
|
? SystemChannels.mouseCursor
|
||||||
|
: const MethodChannel('flutter_custom_cursor');
|
||||||
|
setUp(() {
|
||||||
|
registrations.clear();
|
||||||
|
activations?.clear();
|
||||||
|
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel,
|
||||||
|
(call) async {
|
||||||
|
if (call.method.startsWith('setCustomCursor')) {
|
||||||
|
activations?.add(call.arguments['name'] as String);
|
||||||
|
}
|
||||||
|
if (!call.method.startsWith('createCustomCursor')) return null;
|
||||||
|
final args = call.arguments as Map<dynamic, dynamic>;
|
||||||
|
registrations.add(args);
|
||||||
|
return args['name'];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
tearDown(() =>
|
||||||
|
binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Image decodeNativeCursorRaster(Map<dynamic, dynamic> args) {
|
||||||
|
final bytes = args['buffer'] as Uint8List;
|
||||||
|
return common.isWindows
|
||||||
|
? img.Image.fromBytes(
|
||||||
|
width: args['width'] as int,
|
||||||
|
height: args['height'] as int,
|
||||||
|
bytes: bytes.buffer,
|
||||||
|
bytesOffset: bytes.offsetInBytes,
|
||||||
|
order: img.ChannelOrder.bgra)
|
||||||
|
: img.decodePng(bytes)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CursorTestTexture extends Fake implements TextureModel {
|
||||||
|
@override
|
||||||
|
RxInt getTextureId(int display) => 0.obs;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CursorTestDraw extends Fake implements Canvas {
|
||||||
|
double factor = 1;
|
||||||
|
Offset? position;
|
||||||
|
@override
|
||||||
|
void scale(double sx, [double? sy]) => factor *= sx;
|
||||||
|
@override
|
||||||
|
void drawImage(ui.Image image, Offset offset, Paint paint) =>
|
||||||
|
position = offset * factor;
|
||||||
|
}
|
||||||
276
flutter/test/cursor_web_test.dart
Normal file
276
flutter/test/cursor_web_test.dart
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
// The test drives the cursor lifecycle normally owned by MouseTracker.
|
||||||
|
// ignore_for_file: invalid_use_of_protected_member
|
||||||
|
|
||||||
|
@TestOn('browser')
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:js' as js;
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_hbb/consts.dart';
|
||||||
|
import 'package:flutter_hbb/desktop/pages/remote_page.dart';
|
||||||
|
import 'package:flutter_hbb/models/model.dart' as model;
|
||||||
|
import 'package:flutter_hbb/web/custom_cursor.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'cursor_test_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
_alphaTests();
|
||||||
|
_thinCursorTests();
|
||||||
|
_sourceValidationTests();
|
||||||
|
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||||
|
for (final dpr in [1.0, 2.0]) {
|
||||||
|
for (final (platform, density) in [
|
||||||
|
(kPeerPlatformMacOS, null), (kPeerPlatformMacOS, 0.0),
|
||||||
|
(kPeerPlatformMacOS, 1.0), (kPeerPlatformMacOS, 2.0),
|
||||||
|
(kPeerPlatformLinux, 2.0), (kPeerPlatformWindows, 2.0),
|
||||||
|
]) {
|
||||||
|
testWidgets('Web $style zoom off DPR $dpr $platform density=$density',
|
||||||
|
(tester) => tester.runAsync(() => _checkPolicy(tester, style, dpr,
|
||||||
|
pixelRatio: density, platform: platform)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test('Web cursor aligns CSS hotspots with rounded PNG dimensions', () async {
|
||||||
|
final registered = _captureCursor();
|
||||||
|
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 0.5);
|
||||||
|
addTearDown(canvas.dispose);
|
||||||
|
final ffi = CursorTestFFI(canvas);
|
||||||
|
for (final (hotspot, scale, side, expected) in [
|
||||||
|
((7.0, 7.0), 633 / 1600, 19, (3, 3)),
|
||||||
|
((21.0, 23.0), 633 / 1600, 19, (8, 9)),
|
||||||
|
((22.0, 22.0), 633 / 1600, 19, (9, 9)),
|
||||||
|
((21.0, 23.0), 0.05, 12, (5, 6)),
|
||||||
|
((21.0, 23.0), 0.5, 24, (11, 12)),
|
||||||
|
((7.0, 7.0), 1.0, 48, (7, 7)),
|
||||||
|
]) {
|
||||||
|
final cursor = await _loadCursor(ffi, '$hotspot-$scale',
|
||||||
|
hotspot: hotspot, pixelRatio: null);
|
||||||
|
final session =
|
||||||
|
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
|
||||||
|
await session.activate();
|
||||||
|
final uri = Uri.parse(registered['url'] as String);
|
||||||
|
final bitmap = img.decodePng(uri.data!.contentAsBytes())!;
|
||||||
|
expect((bitmap.width, bitmap.height), (side, side));
|
||||||
|
expect((registered['hotx'], registered['hoty']), expected);
|
||||||
|
session.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sourceValidationTests() {
|
||||||
|
const side = 48;
|
||||||
|
const sourceLimit = 4096;
|
||||||
|
const bytes = side * side * 4;
|
||||||
|
for (final density in [null, 4.0]) {
|
||||||
|
test('Web rejects invalid cursor sources and recovers, density=$density', () async {
|
||||||
|
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 1);
|
||||||
|
addTearDown(canvas.dispose);
|
||||||
|
final cursor = await _loadCursor(CursorTestFFI(canvas), 'source-$density',
|
||||||
|
source: (side, side), pixelRatio: density);
|
||||||
|
final cache = cursor.cache;
|
||||||
|
final image = cursor.image;
|
||||||
|
final event = {
|
||||||
|
'id': 'source-$density', 'width': '$side', 'height': '$side',
|
||||||
|
'hotx': '7', 'hoty': '9',
|
||||||
|
if (density != null) 'scale': '$density',
|
||||||
|
'colors': jsonEncode(List<int>.filled(bytes, 255)),
|
||||||
|
};
|
||||||
|
// Invalid JSON proves geometry is checked before decoding or allocation.
|
||||||
|
const invalidPixels = 'must not decode an invalid source size';
|
||||||
|
for (final invalid in [
|
||||||
|
{'colors': '[1,2,3,4]'},
|
||||||
|
{'colors': jsonEncode(List<int>.filled(bytes + 1, 255))},
|
||||||
|
{'width': '0', 'colors': invalidPixels},
|
||||||
|
{'height': '-1', 'colors': invalidPixels},
|
||||||
|
{'width': '${sourceLimit * 4}', 'height': '${sourceLimit * 4}',
|
||||||
|
'colors': invalidPixels},
|
||||||
|
{'scale': '1e-300'},
|
||||||
|
{'scale': '0.001'},
|
||||||
|
]) {
|
||||||
|
await cursor.updateCursorData({...event, ...invalid});
|
||||||
|
expect(cursor.cache, same(cache));
|
||||||
|
expect(cursor.image, same(image));
|
||||||
|
expect((cursor.hotx, cursor.hoty), (7, 9));
|
||||||
|
}
|
||||||
|
await cursor.updateCursorData(event);
|
||||||
|
expect(cursor.cache, isNot(same(cache)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _thinCursorTests() {
|
||||||
|
for (final style in [kRemoteViewStyleAdaptive, kRemoteViewStyleCustom]) {
|
||||||
|
for (final zoom in [false, true]) {
|
||||||
|
testWidgets('ImagePaint Web $style thin cursor zoom=$zoom', (tester) =>
|
||||||
|
tester.runAsync(() => _checkPolicy(tester, style, 1,
|
||||||
|
zoom: zoom, source: (64, 4), hotspot: (32, 2),
|
||||||
|
expectedSize: zoom ? (32, 2) : (64, 4),
|
||||||
|
expectedHotspot: zoom ? (16, 1) : (32, 2))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test('Web thin cursors keep a raster pixel and an in-bounds hotspot', () async {
|
||||||
|
final registered = _captureCursor();
|
||||||
|
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 0.5);
|
||||||
|
addTearDown(canvas.dispose);
|
||||||
|
final ffi = CursorTestFFI(canvas);
|
||||||
|
for (final (source, hotspot, scale, size, expected) in [
|
||||||
|
((4, 64), (2.0, 32.0), 0.5, (2, 32), (1, 16)),
|
||||||
|
((2, 128), (1.0, 64.0), 0.01, (1, 12), (0, 6)),
|
||||||
|
((128, 2), (64.0, 1.0), 0.01, (12, 1), (6, 0)),
|
||||||
|
]) {
|
||||||
|
final cursor = await _loadCursor(ffi, '$source',
|
||||||
|
source: source, hotspot: hotspot);
|
||||||
|
for (final factor in [1.0, scale, 1.0]) {
|
||||||
|
final session =
|
||||||
|
buildCursorOfCache(cursor, factor, cursor.cache).createSession(1);
|
||||||
|
await session.activate();
|
||||||
|
session.dispose();
|
||||||
|
final png = img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||||
|
expect((png.width, png.height), factor == 1 ? source : size);
|
||||||
|
expect((registered['hotx'], registered['hoty']),
|
||||||
|
factor == 1 ? (hotspot.$1, hotspot.$2) : expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.CursorModel> _loadCursor(model.FFI ffi, String id,
|
||||||
|
{List<int> pixel = const [255, 255, 255, 255],
|
||||||
|
(int, int) source = (48, 48),
|
||||||
|
(double, double) hotspot = (7, 9),
|
||||||
|
double? pixelRatio = 1}) async {
|
||||||
|
final cursor = model.CursorModel(WeakReference(ffi))..id = id;
|
||||||
|
await cursor.updateCursorData({
|
||||||
|
'id': id,
|
||||||
|
'width': '${source.$1}',
|
||||||
|
'height': '${source.$2}',
|
||||||
|
'hotx': '${hotspot.$1}',
|
||||||
|
'hoty': '${hotspot.$2}',
|
||||||
|
if (pixelRatio != null) 'scale': '$pixelRatio',
|
||||||
|
'colors': jsonEncode([for (var i = 0; i < source.$1 * source.$2; i++) ...pixel]),
|
||||||
|
});
|
||||||
|
addTearDown(() async {
|
||||||
|
// Keep the session owner alive across asynchronous image decoding.
|
||||||
|
expect(cursor.parent.target, same(ffi));
|
||||||
|
for (final key in cursor.cachedKeys) {
|
||||||
|
await deleteCustomCursor(key);
|
||||||
|
}
|
||||||
|
cursor.disposeImages();
|
||||||
|
cursor.dispose();
|
||||||
|
});
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _captureCursor() {
|
||||||
|
final originals = {
|
||||||
|
for (final key in ['isMobile', 'getByName', 'setByName'])
|
||||||
|
key: js.context[key]
|
||||||
|
};
|
||||||
|
js.context['isMobile'] = js.allowInterop(() => false);
|
||||||
|
js.context['getByName'] = js.allowInterop((String name, String value) => '');
|
||||||
|
final registered = <String, dynamic>{};
|
||||||
|
js.context['setByName'] = js.allowInterop((String name, String value) {
|
||||||
|
if (name == 'cursor') {
|
||||||
|
registered
|
||||||
|
..clear()
|
||||||
|
..addAll(jsonDecode(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
addTearDown(() {
|
||||||
|
for (final entry in originals.entries) {
|
||||||
|
js.context[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return registered;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkPolicy(WidgetTester tester, String style, double dpr,
|
||||||
|
{bool zoom = false, (int, int) source = (48, 48),
|
||||||
|
(double, double) hotspot = (7, 9), (int, int) expectedSize = (48, 48),
|
||||||
|
(int, int) expectedHotspot = (7, 9), double? pixelRatio = 1,
|
||||||
|
String platform = kPeerPlatformMacOS}) async {
|
||||||
|
final registered = _captureCursor();
|
||||||
|
final canvas = CursorTestCanvas(1, style: style, scale: 0.5);
|
||||||
|
addTearDown(canvas.dispose);
|
||||||
|
final ffi = CursorTestFFI(canvas);
|
||||||
|
ffi.ffiModel.pi.platform = platform;
|
||||||
|
// Retina export changes the bitmap too; varying metadata alone misses this boundary.
|
||||||
|
final density = platform == kPeerPlatformMacOS && pixelRatio == 2 ? 2 : 1;
|
||||||
|
final cursor = await _loadCursor(ffi, '$style-$dpr-$zoom-$source-$platform-$pixelRatio',
|
||||||
|
source: (source.$1 * density, source.$2 * density), pixelRatio: pixelRatio,
|
||||||
|
hotspot: (hotspot.$1 * density, hotspot.$2 * density));
|
||||||
|
await tester.pumpWidget(MediaQuery(
|
||||||
|
data: MediaQueryData(devicePixelRatio: dpr),
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<model.ImageModel>(create: (_) => CursorTestImage()),
|
||||||
|
ChangeNotifierProvider<model.CanvasModel>.value(value: canvas),
|
||||||
|
ChangeNotifierProvider<model.CursorModel>.value(value: cursor),
|
||||||
|
],
|
||||||
|
child: ImagePaint(
|
||||||
|
ffi: ffi,
|
||||||
|
id: 'web-cursor-test',
|
||||||
|
zoomCursor: zoom.obs,
|
||||||
|
cursorOverImage: true.obs,
|
||||||
|
keyboardEnabled: true.obs,
|
||||||
|
remoteCursorMoved: false.obs)),
|
||||||
|
));
|
||||||
|
final session = tester
|
||||||
|
.widget<MouseRegion>(find.byType(MouseRegion))
|
||||||
|
.cursor
|
||||||
|
.createSession(1);
|
||||||
|
await session.activate();
|
||||||
|
session.dispose();
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
final png =
|
||||||
|
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||||
|
expect((png.width, png.height), expectedSize);
|
||||||
|
expect((registered['hotx'], registered['hoty']), expectedHotspot);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _alphaTests() {
|
||||||
|
for (final (density, pixel) in [
|
||||||
|
(1.0, [255, 255, 255, 128]),
|
||||||
|
(2.0, [255, 128, 64, 128]),
|
||||||
|
(1.0, [255, 128, 64, 112]),
|
||||||
|
(1.0, [0, 0, 0, 0]),
|
||||||
|
(1.0, [255, 255, 255, 255]),
|
||||||
|
// Old macOS hosts send straight alpha without density metadata.
|
||||||
|
(0.0, [255, 128, 64, 128]),
|
||||||
|
(null, [80, 40, 20, 128]),
|
||||||
|
]) {
|
||||||
|
test('Web macOS cursor density $density preserves RGBA $pixel',
|
||||||
|
() => _checkAlpha(density, pixel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkAlpha(double? density, List<int> pixel) async {
|
||||||
|
final registered = _captureCursor();
|
||||||
|
final canvas = CursorTestCanvas(1, style: kRemoteViewStyleAdaptive, scale: 0.5);
|
||||||
|
addTearDown(canvas.dispose);
|
||||||
|
final ffi = CursorTestFFI(canvas)..ffiModel.pi.platform = kPeerPlatformMacOS;
|
||||||
|
final cursor = await _loadCursor(ffi, 'alpha-$density-$pixel',
|
||||||
|
pixel: pixel, pixelRatio: density);
|
||||||
|
// Cover both the painted remote cursor and the initial CSS cursor PNG.
|
||||||
|
final straight = await cursor.image!
|
||||||
|
.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
|
||||||
|
expect(straight!.buffer.asUint8List(0, 4), pixel);
|
||||||
|
for (final scale in [1.0, 0.5, 1.0]) {
|
||||||
|
final session =
|
||||||
|
buildCursorOfCache(cursor, scale, cursor.cache).createSession(1);
|
||||||
|
await session.activate();
|
||||||
|
session.dispose();
|
||||||
|
final png =
|
||||||
|
img.decodePng(Uri.parse(registered['url']).data!.contentAsBytes())!;
|
||||||
|
final color = png.getPixel(png.width ~/ 2, png.height ~/ 2);
|
||||||
|
expect([color.r, color.g, color.b, color.a], pixel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,12 +310,16 @@ message KeyEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message CursorData {
|
message CursorData {
|
||||||
uint64 id = 1;
|
uint64 id = 1 [jstype = JS_STRING];
|
||||||
sint32 hotx = 2;
|
sint32 hotx = 2;
|
||||||
sint32 hoty = 3;
|
sint32 hoty = 3;
|
||||||
int32 width = 4;
|
int32 width = 4;
|
||||||
int32 height = 5;
|
int32 height = 5;
|
||||||
bytes colors = 6;
|
bytes colors = 6;
|
||||||
|
// Physical bitmap pixels per remote logical pixel; 0 means unknown density.
|
||||||
|
double scale = 7;
|
||||||
|
// Optional physical image. Legacy receivers keep using the logical-sized fields above.
|
||||||
|
CursorData high_resolution = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CursorPosition {
|
message CursorPosition {
|
||||||
@@ -999,7 +1003,7 @@ message Message {
|
|||||||
AudioFrame audio_frame = 11;
|
AudioFrame audio_frame = 11;
|
||||||
CursorData cursor_data = 12;
|
CursorData cursor_data = 12;
|
||||||
CursorPosition cursor_position = 13;
|
CursorPosition cursor_position = 13;
|
||||||
uint64 cursor_id = 14;
|
uint64 cursor_id = 14 [jstype = JS_STRING];
|
||||||
KeyEvent key_event = 15;
|
KeyEvent key_event = 15;
|
||||||
Clipboard clipboard = 16;
|
Clipboard clipboard = 16;
|
||||||
FileAction file_action = 17;
|
FileAction file_action = 17;
|
||||||
|
|||||||
@@ -320,6 +320,9 @@ pub struct WaylandDisplayInfo {
|
|||||||
pub width: i32,
|
pub width: i32,
|
||||||
pub height: i32,
|
pub height: i32,
|
||||||
pub logical_size: Option<(i32, i32)>,
|
pub logical_size: Option<(i32, i32)>,
|
||||||
|
// wl_output.scale is independent of desktop coordinates. Older probes omit it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub scale_factor: i32,
|
||||||
pub refresh_rate: i32,
|
pub refresh_rate: i32,
|
||||||
/// Output rotation in degrees (0/90/180/270), from `wl_output.geometry`. The mode keeps its
|
/// Output rotation in degrees (0/90/180/270), from `wl_output.geometry`. The mode keeps its
|
||||||
/// unrotated dimensions and `logical_size` arrives already swapped, so without this field a
|
/// unrotated dimensions and `logical_size` arrives already swapped, so without this field a
|
||||||
@@ -450,6 +453,7 @@ fn collect_wayland_displays(conn: &Connection) -> ResultType<Vec<WaylandDisplayI
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
logical_size,
|
logical_size,
|
||||||
|
scale_factor: info.scale_factor,
|
||||||
refresh_rate,
|
refresh_rate,
|
||||||
transform,
|
transform,
|
||||||
});
|
});
|
||||||
@@ -542,9 +546,11 @@ mod tests {
|
|||||||
let old = r#"{"name":"HDMI-1","x":0,"y":0,"width":1920,"height":1080,"logical_size":null,"refresh_rate":60}"#;
|
let old = r#"{"name":"HDMI-1","x":0,"y":0,"width":1920,"height":1080,"logical_size":null,"refresh_rate":60}"#;
|
||||||
let info: WaylandDisplayInfo = serde_json::from_str(old).unwrap();
|
let info: WaylandDisplayInfo = serde_json::from_str(old).unwrap();
|
||||||
assert_eq!(info.transform, 0);
|
assert_eq!(info.transform, 0);
|
||||||
|
assert_eq!(info.scale_factor, 0);
|
||||||
let roundtrip: WaylandDisplayInfo =
|
let roundtrip: WaylandDisplayInfo =
|
||||||
serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&info).unwrap()).unwrap();
|
||||||
assert_eq!(roundtrip.transform, 0);
|
assert_eq!(roundtrip.transform, 0);
|
||||||
|
assert_eq!(roundtrip.scale_factor, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Submodule libs/hbb_common updated: 3bebd27528...29cf7cbe4d
@@ -248,6 +248,25 @@ pub fn wayland_failure_stamped() -> bool {
|
|||||||
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
|
LAST_FAILED_LOOKUP.lock().unwrap().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
pub enum CachedDisplays {
|
||||||
|
Busy,
|
||||||
|
Ready(Option<Arc<Displays>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cursor polls must neither wait for discovery nor mistake contention for missing metadata.
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
pub fn get_cached_displays() -> CachedDisplays {
|
||||||
|
match DISPLAYS.try_lock() {
|
||||||
|
Ok(cache) => CachedDisplays::Ready(cache.clone()),
|
||||||
|
Err(std::sync::TryLockError::WouldBlock) => CachedDisplays::Busy,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to read cached Wayland displays: {}", err);
|
||||||
|
CachedDisplays::Ready(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_displays() -> Arc<Displays> {
|
pub fn get_displays() -> Arc<Displays> {
|
||||||
let mut lock = DISPLAYS.lock().unwrap();
|
let mut lock = DISPLAYS.lock().unwrap();
|
||||||
match lock.as_ref() {
|
match lock.as_ref() {
|
||||||
@@ -337,6 +356,17 @@ pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<Display
|
|||||||
}
|
}
|
||||||
match enumerate_displays() {
|
match enumerate_displays() {
|
||||||
Ok(displays) => {
|
Ok(displays) => {
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
{
|
||||||
|
// Output density can change without moving any desktop-coordinate rectangle.
|
||||||
|
let mut cache = DISPLAYS.lock().unwrap();
|
||||||
|
if let Some(updated) = cache
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|cached| updated_cursor_scales(cached, &displays))
|
||||||
|
{
|
||||||
|
*cache = Some(Arc::new(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays)))
|
desktop_rect_of(&displays).map(|rect| (rect, logical_rects_of(&displays)))
|
||||||
}
|
}
|
||||||
Err(_err) => {
|
Err(_err) => {
|
||||||
@@ -347,6 +377,30 @@ pub fn get_layout_for_uinput_live() -> Option<((i32, i32, i32, i32), Vec<Display
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
fn updated_cursor_scales(cached: &Displays, live: &[WaylandDisplayInfo]) -> Option<Displays> {
|
||||||
|
let mut displays = cached.displays.clone();
|
||||||
|
let mut changed = false;
|
||||||
|
for output in &mut displays {
|
||||||
|
// Geometry changes retain the existing layout invalidation/rebuild path.
|
||||||
|
let Some(current) = live.iter().find(|d| {
|
||||||
|
d.name == output.name
|
||||||
|
&& (d.x, d.y) == (output.x, output.y)
|
||||||
|
&& (d.width, d.height) == (output.width, output.height)
|
||||||
|
&& d.transform == output.transform
|
||||||
|
&& d.logical_size == output.logical_size
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
changed |= output.scale_factor != current.scale_factor;
|
||||||
|
output.scale_factor = current.scale_factor;
|
||||||
|
}
|
||||||
|
changed.then_some(Displays {
|
||||||
|
primary: cached.primary,
|
||||||
|
displays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> {
|
fn desktop_rect_of(displays: &[WaylandDisplayInfo]) -> Option<(i32, i32, i32, i32)> {
|
||||||
if displays.is_empty() {
|
if displays.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -516,6 +570,27 @@ fn map_axis(v: i32, base_origin: i32, base_extent: i32, live_origin: i32, live_e
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
#[test]
|
||||||
|
fn cursor_metadata_does_not_wait_for_display_discovery() {
|
||||||
|
let cache = DISPLAYS.lock().unwrap();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let reader = std::thread::spawn(move || {
|
||||||
|
tx.send(matches!(get_cached_displays(), CachedDisplays::Busy))
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
// Discovery owns this lock until it completes or times out.
|
||||||
|
let result = rx.recv_timeout(Duration::from_secs(1));
|
||||||
|
drop(cache);
|
||||||
|
reader.join().unwrap();
|
||||||
|
assert!(result.unwrap());
|
||||||
|
|
||||||
|
clear_wayland_displays_cache();
|
||||||
|
for _ in 0..100 {
|
||||||
|
assert!(matches!(get_cached_displays(), CachedDisplays::Ready(None)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_lookup_backoff_boundaries() {
|
fn test_lookup_backoff_boundaries() {
|
||||||
// Future `now`s sidestep Instant subtraction, which can panic near boot.
|
// Future `now`s sidestep Instant subtraction, which can panic near boot.
|
||||||
@@ -564,6 +639,7 @@ mod tests {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
logical_size,
|
logical_size,
|
||||||
|
scale_factor: 1,
|
||||||
refresh_rate: 60,
|
refresh_rate: 60,
|
||||||
transform: 0,
|
transform: 0,
|
||||||
}
|
}
|
||||||
@@ -574,6 +650,33 @@ mod tests {
|
|||||||
assert_eq!(desktop_rect_of(&[]), None);
|
assert_eq!(desktop_rect_of(&[]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
#[test]
|
||||||
|
fn output_scale_refresh_preserves_layout_and_existing_snapshots() {
|
||||||
|
let mut output = display(0, 0, 2560, 1600, Some((2560, 1600)));
|
||||||
|
output.name = "eDP-1".into();
|
||||||
|
output.scale_factor = 3;
|
||||||
|
let cached = Displays {
|
||||||
|
primary: 0,
|
||||||
|
displays: vec![output.clone()],
|
||||||
|
};
|
||||||
|
output.scale_factor = 2;
|
||||||
|
let updated = updated_cursor_scales(&cached, &[output.clone()]).unwrap();
|
||||||
|
assert_eq!(updated.displays[0].scale_factor, 2);
|
||||||
|
assert_eq!(cached.displays[0].scale_factor, 3);
|
||||||
|
assert_eq!(updated.primary, cached.primary);
|
||||||
|
assert_eq!(
|
||||||
|
logical_rects_of(&updated.displays),
|
||||||
|
logical_rects_of(&cached.displays)
|
||||||
|
);
|
||||||
|
assert!(updated_cursor_scales(&updated, &[output.clone()]).is_none());
|
||||||
|
output.name = "HDMI-1".into();
|
||||||
|
assert!(updated_cursor_scales(&cached, &[output.clone()]).is_none());
|
||||||
|
output.name = "eDP-1".into();
|
||||||
|
output.logical_size = Some((1280, 800));
|
||||||
|
assert!(updated_cursor_scales(&cached, &[output]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_desktop_rect_single_display_uses_physical_size() {
|
fn test_desktop_rect_single_display_uses_physical_size() {
|
||||||
let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))];
|
let displays = [display(0, 0, 2880, 1800, Some((1859, 1162)))];
|
||||||
|
|||||||
@@ -1508,7 +1508,11 @@ impl<T: InvokeUiSession> Remote<T> {
|
|||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Some(message::Union::CursorData(cd)) => {
|
Some(message::Union::CursorData(cd)) => {
|
||||||
self.handler.set_cursor_data(cd);
|
let id = cd.id;
|
||||||
|
match decode_cursor_data(cd) {
|
||||||
|
Ok(cd) => self.handler.set_cursor_data(cd),
|
||||||
|
Err(err) => log::warn!("Rejected cursor {id}: {err}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(message::Union::CursorId(id)) => {
|
Some(message::Union::CursorId(id)) => {
|
||||||
self.handler.set_cursor_id(id.to_string());
|
self.handler.set_cursor_id(id.to_string());
|
||||||
@@ -2542,6 +2546,53 @@ impl<T: InvokeUiSession> Remote<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both UI handlers receive validated, uncompressed RGBA from the receive loop.
|
||||||
|
fn decode_cursor_data(data: CursorData) -> hbb_common::ResultType<CursorData> {
|
||||||
|
use hbb_common::{anyhow::anyhow, bail};
|
||||||
|
|
||||||
|
const MAX_CURSOR_SIZE: i32 = 4096;
|
||||||
|
const RGBA_CHANNELS: usize = 4;
|
||||||
|
|
||||||
|
let mut cd = data;
|
||||||
|
// Sciter keeps the legacy outer image; Flutter uses the physical variant.
|
||||||
|
#[cfg(feature = "flutter")]
|
||||||
|
if let Some(physical) = cd.high_resolution.take() {
|
||||||
|
cd = physical;
|
||||||
|
}
|
||||||
|
if !(1..=MAX_CURSOR_SIZE).contains(&cd.width) || !(1..=MAX_CURSOR_SIZE).contains(&cd.height) {
|
||||||
|
bail!("invalid source size {}x{}", cd.width, cd.height);
|
||||||
|
}
|
||||||
|
if !(0..cd.width).contains(&cd.hotx) || !(0..cd.height).contains(&cd.hoty) {
|
||||||
|
bail!(
|
||||||
|
"hotspot ({},{}) is outside the cursor image",
|
||||||
|
cd.hotx,
|
||||||
|
cd.hoty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Zero preserves legacy sizing; positive density must bound the logical image too.
|
||||||
|
if !cd.scale.is_finite()
|
||||||
|
|| cd.scale < 0.0
|
||||||
|
|| (cd.scale > 0.0
|
||||||
|
&& (f64::from(cd.width) / cd.scale > f64::from(MAX_CURSOR_SIZE)
|
||||||
|
|| f64::from(cd.height) / cd.scale > f64::from(MAX_CURSOR_SIZE)))
|
||||||
|
{
|
||||||
|
bail!("invalid cursor density {}", cd.scale);
|
||||||
|
}
|
||||||
|
let expected = (cd.width as usize)
|
||||||
|
.checked_mul(cd.height as usize)
|
||||||
|
.and_then(|pixels| pixels.checked_mul(RGBA_CHANNELS))
|
||||||
|
.ok_or_else(|| anyhow!("cursor RGBA size overflow"))?;
|
||||||
|
let colors = zstd::bulk::decompress(&cd.colors, expected)?;
|
||||||
|
if colors.len() != expected {
|
||||||
|
bail!(
|
||||||
|
"invalid RGBA length: expected {expected}, got {}",
|
||||||
|
colors.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cd.colors = colors.into();
|
||||||
|
Ok(cd)
|
||||||
|
}
|
||||||
|
|
||||||
struct RemoveJob {
|
struct RemoveJob {
|
||||||
files: Vec<FileEntry>,
|
files: Vec<FileEntry>,
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -642,7 +642,7 @@ impl FlutterHandler {
|
|||||||
|
|
||||||
impl InvokeUiSession for FlutterHandler {
|
impl InvokeUiSession for FlutterHandler {
|
||||||
fn set_cursor_data(&self, cd: CursorData) {
|
fn set_cursor_data(&self, cd: CursorData) {
|
||||||
let colors = hbb_common::compress::decompress(&cd.colors);
|
let colors = &cd.colors;
|
||||||
self.push_event(
|
self.push_event(
|
||||||
"cursor_data",
|
"cursor_data",
|
||||||
&[
|
&[
|
||||||
@@ -651,6 +651,7 @@ impl InvokeUiSession for FlutterHandler {
|
|||||||
("hoty", &cd.hoty.to_string()),
|
("hoty", &cd.hoty.to_string()),
|
||||||
("width", &cd.width.to_string()),
|
("width", &cd.width.to_string()),
|
||||||
("height", &cd.height.to_string()),
|
("height", &cd.height.to_string()),
|
||||||
|
("scale", &cd.scale.to_string()),
|
||||||
(
|
(
|
||||||
"colors",
|
"colors",
|
||||||
&serde_json::ser::to_string(&colors).unwrap_or("".to_owned()),
|
&serde_json::ser::to_string(&colors).unwrap_or("".to_owned()),
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ use std::{
|
|||||||
use terminfo::{capability as cap, Database};
|
use terminfo::{capability as cap, Database};
|
||||||
use wallpaper;
|
use wallpaper;
|
||||||
|
|
||||||
|
// Cursor density is capture metadata, independent of either endpoint's UI.
|
||||||
|
mod cursor;
|
||||||
|
|
||||||
pub const PA_SAMPLE_RATE: u32 = 48000;
|
pub const PA_SAMPLE_RATE: u32 = 48000;
|
||||||
static mut UNMODIFIED: bool = true;
|
static mut UNMODIFIED: bool = true;
|
||||||
|
|
||||||
@@ -570,7 +573,9 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
|||||||
// polled there is a live session, which is the case the latch reads correctly.
|
// polled there is a live session, which is the case the latch reads correctly.
|
||||||
#[cfg(feature = "drm")]
|
#[cfg(feature = "drm")]
|
||||||
if !is_x11() {
|
if !is_x11() {
|
||||||
if let Some(id) = crate::server::drm_capturer::drm_cursor_id() {
|
let cursor = cursor::drm_snapshot(|c| c.id)?
|
||||||
|
.map(|(id, scale)| cursor::cache_id(id, scale));
|
||||||
|
if let Some(id) = cursor {
|
||||||
// In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays;
|
// In a mixed DRM + PipeWire session the DRM streams only cover the DRM-backed displays;
|
||||||
// when the pointer sits on a PipeWire-served display every DRM stream reports the hidden
|
// when the pointer sits on a PipeWire-served display every DRM stream reports the hidden
|
||||||
// sentinel. Returning that sentinel here would hide the cursor globally, including on the
|
// sentinel. Returning that sentinel here would hide the cursor globally, including on the
|
||||||
@@ -598,6 +603,7 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
let res = res.map(cursor::x11_cursor_id);
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +616,8 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
|||||||
// agree anyway, since a caller that took the DRM branch there has to take it here.
|
// agree anyway, since a caller that took the DRM branch there has to take it here.
|
||||||
#[cfg(feature = "drm")]
|
#[cfg(feature = "drm")]
|
||||||
if !is_x11() {
|
if !is_x11() {
|
||||||
if let Some(c) = crate::server::drm_capturer::drm_cursor() {
|
let cursor = cursor::drm_snapshot(Clone::clone)?;
|
||||||
|
if let Some((c, scale)) = cursor {
|
||||||
// See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In
|
// See get_cursor(): a hidden DRM sentinel is authoritative only in a pure-DRM session. In
|
||||||
// a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served
|
// a mixed DRM + PipeWire session fall through so the PipeWire display's cursor is served
|
||||||
// by the normal path instead of being hidden everywhere.
|
// by the normal path instead of being hidden everywhere.
|
||||||
@@ -618,7 +625,8 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
|||||||
|| !crate::server::display_service::has_non_drm_backed_display()
|
|| !crate::server::display_service::has_non_drm_backed_display()
|
||||||
{
|
{
|
||||||
let mut cd: CursorData = Default::default();
|
let mut cd: CursorData = Default::default();
|
||||||
cd.id = c.id;
|
cd.id = cursor::cache_id(c.id, scale);
|
||||||
|
cd.scale = scale;
|
||||||
cd.width = c.width;
|
cd.width = c.width;
|
||||||
cd.height = c.height;
|
cd.height = c.height;
|
||||||
cd.hotx = c.hotx;
|
cd.hotx = c.hotx;
|
||||||
@@ -628,20 +636,23 @@ pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let scale = cursor::x11_cursor_scale();
|
||||||
|
let matches = |id| cursor::cache_id(id, scale) == hcursor;
|
||||||
let mut res = None;
|
let mut res = None;
|
||||||
DISPLAY.with(|conn| {
|
DISPLAY.with(|conn| {
|
||||||
if let Ok(ref mut d) = conn.try_borrow_mut() {
|
if let Ok(ref mut d) = conn.try_borrow_mut() {
|
||||||
if !d.is_null() {
|
if !d.is_null() {
|
||||||
unsafe {
|
unsafe {
|
||||||
let img = XFixesGetCursorImage(**d);
|
let img = XFixesGetCursorImage(**d);
|
||||||
if !img.is_null() && hcursor == (*img).cursor_serial as u64 {
|
if !img.is_null() && matches((*img).cursor_serial as u64) {
|
||||||
let mut cd: CursorData = Default::default();
|
let mut cd: CursorData = Default::default();
|
||||||
cd.hotx = (*img).xhot as _;
|
cd.hotx = (*img).xhot as _;
|
||||||
cd.hoty = (*img).yhot as _;
|
cd.hoty = (*img).yhot as _;
|
||||||
cd.width = (*img).width as _;
|
cd.width = (*img).width as _;
|
||||||
cd.height = (*img).height as _;
|
cd.height = (*img).height as _;
|
||||||
// to-do: how about if it is 0
|
// to-do: how about if it is 0
|
||||||
cd.id = (*img).cursor_serial as _;
|
cd.id = hcursor;
|
||||||
|
cd.scale = scale;
|
||||||
let pixels =
|
let pixels =
|
||||||
std::slice::from_raw_parts((*img).pixels, (cd.width * cd.height) as _);
|
std::slice::from_raw_parts((*img).pixels, (cd.width * cd.height) as _);
|
||||||
// cd.colors.resize(pixels.len() * 4, 0);
|
// cd.colors.resize(pixels.len() * 4, 0);
|
||||||
|
|||||||
196
src/platform/linux/cursor.rs
Normal file
196
src/platform/linux/cursor.rs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
use hbb_common::{anyhow::Context, bail, log, ResultType};
|
||||||
|
use std::{
|
||||||
|
cell::{Cell, RefCell},
|
||||||
|
collections::hash_map::DefaultHasher,
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
};
|
||||||
|
use x11rb::{protocol::xproto::ConnectionExt, rust_connection::RustConnection, NONE};
|
||||||
|
|
||||||
|
mod xsettings;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod x11_tests;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SETTINGS: RefCell<Option<(RustConnection, usize)>> = const { RefCell::new(None) };
|
||||||
|
static X11_SCALE: Cell<Option<f64>> = const { Cell::new(Some(0.0)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cache_id(id: u64, scale: f64) -> u64 {
|
||||||
|
// Legacy Web decoders require JS-safe integers; zero is the service's initial ID.
|
||||||
|
const MAX_CURSOR_ID: u64 = (1 << 53) - 1;
|
||||||
|
if scale == 0.0 {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
let mut hash = DefaultHasher::new();
|
||||||
|
(id, scale.to_bits()).hash(&mut hash);
|
||||||
|
hash.finish() % MAX_CURSOR_ID + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn x11_cursor_id(id: u64) -> u64 {
|
||||||
|
let scale = X11_SCALE.with(|last| match x11_scale() {
|
||||||
|
Ok(scale) => {
|
||||||
|
last.set(Some(scale));
|
||||||
|
scale
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// XSETTINGS is optional; warn once per failure streak without
|
||||||
|
// turning valid XFixes cursor updates into service errors.
|
||||||
|
if last.replace(None).is_some() {
|
||||||
|
log::warn!("Failed to read XSETTINGS cursor density; using unknown density: {err}");
|
||||||
|
}
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cache_id(id, scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn x11_cursor_scale() -> f64 {
|
||||||
|
// The cursor service reads the ID and bitmap on the same thread. Reuse
|
||||||
|
// that poll's density even if the settings manager changes between them.
|
||||||
|
X11_SCALE.with(|last| last.get().unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn x11_scale() -> ResultType<f64> {
|
||||||
|
if !super::is_x11() {
|
||||||
|
return Ok(0.0);
|
||||||
|
}
|
||||||
|
SETTINGS.with(|settings| {
|
||||||
|
let mut state = settings.try_borrow_mut()?;
|
||||||
|
if state.is_none() {
|
||||||
|
*state = Some(x11rb::connect(None)?);
|
||||||
|
}
|
||||||
|
let (connection, screen) = state.as_ref().context("Missing XSETTINGS connection")?;
|
||||||
|
let result = read_settings(connection, *screen);
|
||||||
|
if result.is_err() {
|
||||||
|
*state = None;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_settings(connection: &RustConnection, screen: usize) -> ResultType<f64> {
|
||||||
|
let selection = connection
|
||||||
|
.intern_atom(true, format!("_XSETTINGS_S{screen}").as_bytes())?
|
||||||
|
.reply()?
|
||||||
|
.atom;
|
||||||
|
if selection == NONE {
|
||||||
|
return Ok(0.0);
|
||||||
|
}
|
||||||
|
let owner = connection.get_selection_owner(selection)?.reply()?.owner;
|
||||||
|
if owner == NONE {
|
||||||
|
return Ok(0.0);
|
||||||
|
}
|
||||||
|
let property = connection
|
||||||
|
.intern_atom(true, b"_XSETTINGS_SETTINGS")?
|
||||||
|
.reply()?
|
||||||
|
.atom;
|
||||||
|
let reply = connection
|
||||||
|
.get_property(false, owner, property, property, 0, u32::MAX)?
|
||||||
|
.reply()?;
|
||||||
|
if reply.format != 8 || reply.bytes_after != 0 {
|
||||||
|
bail!("Incomplete XSETTINGS property");
|
||||||
|
}
|
||||||
|
// Xft/DPI includes text scaling; it is not the cursor's pixel density.
|
||||||
|
// Zero explicitly keeps the existing policy on desktops without a window scale.
|
||||||
|
Ok(xsettings::scale(&reply.value)?.unwrap_or(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
pub(super) fn drm_snapshot<T>(
|
||||||
|
f: impl Fn(&crate::server::drm_capturer::DrmCursorData) -> T,
|
||||||
|
) -> ResultType<Option<(T, f64)>> {
|
||||||
|
crate::server::drm_capturer::drm_cursor_snapshot(f)
|
||||||
|
.map(|(cursor, display)| {
|
||||||
|
// A hidden cursor or an unavailable display probe has no density metadata.
|
||||||
|
let scale = display
|
||||||
|
.as_ref()
|
||||||
|
.map(wayland_scale)
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
Ok((cursor, scale))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
fn wayland_scale(display: &base::platform::linux::WaylandDisplayInfo) -> ResultType<f64> {
|
||||||
|
// Missing logical geometry means unknown density, as with older senders.
|
||||||
|
let Some((logical_width, logical_height)) = display.logical_size else {
|
||||||
|
return Ok(0.0);
|
||||||
|
};
|
||||||
|
if logical_width <= 0 || logical_height <= 0 || display.width <= 0 || display.height <= 0 {
|
||||||
|
bail!("Invalid Wayland cursor display dimensions");
|
||||||
|
}
|
||||||
|
// Logical geometry is already rotated; the physical mode dimensions are not.
|
||||||
|
let width = if matches!(display.transform, 90 | 270) {
|
||||||
|
display.height
|
||||||
|
} else {
|
||||||
|
display.width
|
||||||
|
};
|
||||||
|
let scale = f64::from(width) / f64::from(logical_width);
|
||||||
|
// Mutter can report physical desktop coordinates even at 2x/3x output scale.
|
||||||
|
// Keep geometry-derived fractional densities for logically scaled desktops.
|
||||||
|
Ok(if scale == 1.0 && display.scale_factor > 1 {
|
||||||
|
f64::from(display.scale_factor)
|
||||||
|
} else {
|
||||||
|
scale
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_cache_ids_fit_legacy_web_numbers() {
|
||||||
|
for cursor in [1, 123, u64::MAX] {
|
||||||
|
assert_eq!(cache_id(cursor, 0.0), cursor);
|
||||||
|
for scale in [1.0, 1.25, 2.0] {
|
||||||
|
assert!((1..=9_007_199_254_740_991).contains(&cache_id(cursor, scale)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
#[test]
|
||||||
|
fn cursor_density_tracks_fractional_rotation_and_cache_identity() {
|
||||||
|
let display = base::platform::linux::WaylandDisplayInfo {
|
||||||
|
name: "test".into(),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
logical_size: Some((600, 960)),
|
||||||
|
scale_factor: 2,
|
||||||
|
refresh_rate: 60000,
|
||||||
|
transform: 90,
|
||||||
|
};
|
||||||
|
assert_eq!(wayland_scale(&display).unwrap(), 4.0 / 3.0);
|
||||||
|
assert_ne!(cache_id(1, 1.0), cache_id(1, 2.0));
|
||||||
|
assert_eq!(cache_id(1, 0.0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "drm")]
|
||||||
|
#[test]
|
||||||
|
fn cursor_density_distinguishes_output_scale_from_desktop_coordinates() {
|
||||||
|
for (scale_factor, logical_size, expected) in [
|
||||||
|
(3, (2560, 1600), 3.0), // GNOME can use physical desktop coordinates.
|
||||||
|
(2, (2048, 1280), 1.25), // Keep fractional scaling from logical geometry.
|
||||||
|
(0, (2560, 1600), 1.0), // Older probe snapshots omit wl_output.scale.
|
||||||
|
] {
|
||||||
|
let display = base::platform::linux::WaylandDisplayInfo {
|
||||||
|
name: "eDP-1".into(),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 2560,
|
||||||
|
height: 1600,
|
||||||
|
logical_size: Some(logical_size),
|
||||||
|
scale_factor,
|
||||||
|
refresh_rate: 120000,
|
||||||
|
transform: 0,
|
||||||
|
};
|
||||||
|
assert_eq!(wayland_scale(&display).unwrap(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/platform/linux/cursor/x11_tests.rs
Normal file
171
src/platform/linux/cursor/x11_tests.rs
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
use super::super::{get_cursor, get_cursor_data};
|
||||||
|
use hbb_common::ResultType;
|
||||||
|
use x11rb::{
|
||||||
|
connection::Connection, protocol::xproto::*, rust_connection::RustConnection,
|
||||||
|
wrapper::ConnectionExt as _, CURRENT_TIME,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Settings {
|
||||||
|
connection: RustConnection,
|
||||||
|
owner: Window,
|
||||||
|
property: Atom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
fn new() -> ResultType<Self> {
|
||||||
|
// This test replaces the settings manager; never run on a real desktop.
|
||||||
|
assert_eq!(
|
||||||
|
std::env::var("RUSTDESK_X11_CURSOR_TEST").as_deref(),
|
||||||
|
Ok("1")
|
||||||
|
);
|
||||||
|
let (connection, screen) = x11rb::connect(None)?;
|
||||||
|
let selection = connection
|
||||||
|
.intern_atom(false, format!("_XSETTINGS_S{screen}").as_bytes())?
|
||||||
|
.reply()?
|
||||||
|
.atom;
|
||||||
|
let property = connection
|
||||||
|
.intern_atom(false, b"_XSETTINGS_SETTINGS")?
|
||||||
|
.reply()?
|
||||||
|
.atom;
|
||||||
|
let owner = connection.generate_id()?;
|
||||||
|
connection
|
||||||
|
.create_window(
|
||||||
|
0,
|
||||||
|
owner,
|
||||||
|
connection.setup().roots[screen].root,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
WindowClass::INPUT_OUTPUT,
|
||||||
|
0,
|
||||||
|
&CreateWindowAux::new(),
|
||||||
|
)?
|
||||||
|
.check()?;
|
||||||
|
connection
|
||||||
|
.set_selection_owner(owner, selection, CURRENT_TIME)?
|
||||||
|
.check()?;
|
||||||
|
Ok(Self {
|
||||||
|
connection,
|
||||||
|
owner,
|
||||||
|
property,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn property(&self, bytes: &[u8]) {
|
||||||
|
self.connection
|
||||||
|
.change_property8(
|
||||||
|
PropMode::REPLACE,
|
||||||
|
self.owner,
|
||||||
|
self.property,
|
||||||
|
self.property,
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.check()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale(&self, value: i32) {
|
||||||
|
const ALIGNMENT: usize = 4;
|
||||||
|
let name = b"Gdk/WindowScalingFactor";
|
||||||
|
let mut bytes = vec![0, 0, 0, 0];
|
||||||
|
bytes.extend(1_u32.to_le_bytes()); // Serial.
|
||||||
|
bytes.extend(1_u32.to_le_bytes()); // One integer setting.
|
||||||
|
bytes.extend([0, 0]);
|
||||||
|
bytes.extend((name.len() as u16).to_le_bytes());
|
||||||
|
bytes.extend(name);
|
||||||
|
bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0);
|
||||||
|
bytes.extend(1_u32.to_le_bytes());
|
||||||
|
bytes.extend(value.to_le_bytes());
|
||||||
|
self.property(&bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrow(&self) {
|
||||||
|
const LEFT_PTR: u16 = 68;
|
||||||
|
const WHITE: u16 = u16::MAX;
|
||||||
|
let font = self.connection.generate_id().unwrap();
|
||||||
|
let cursor = self.connection.generate_id().unwrap();
|
||||||
|
self.connection
|
||||||
|
.open_font(font, b"cursor")
|
||||||
|
.unwrap()
|
||||||
|
.check()
|
||||||
|
.unwrap();
|
||||||
|
self.connection
|
||||||
|
.create_glyph_cursor(
|
||||||
|
cursor,
|
||||||
|
font,
|
||||||
|
font,
|
||||||
|
LEFT_PTR,
|
||||||
|
LEFT_PTR + 1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
WHITE,
|
||||||
|
WHITE,
|
||||||
|
WHITE,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.check()
|
||||||
|
.unwrap();
|
||||||
|
self.connection
|
||||||
|
.change_window_attributes(
|
||||||
|
self.connection.setup().roots[0].root,
|
||||||
|
&ChangeWindowAttributesAux::new().cursor(cursor),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.check()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor(scale: f64) -> u64 {
|
||||||
|
let id = get_cursor().unwrap().expect("Xvfb must have a cursor");
|
||||||
|
let data = get_cursor_data(id).unwrap();
|
||||||
|
assert_eq!((data.id, data.scale), (id, scale));
|
||||||
|
assert!(data.width > 0 && data.height > 0 && !data.colors.is_empty());
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"]
|
||||||
|
fn x11_metadata_errors_preserve_cursor_delivery_and_recover() {
|
||||||
|
let settings = Settings::new().unwrap();
|
||||||
|
let unknown = cursor(0.0); // Selection owner exists but has no property.
|
||||||
|
settings.scale(2);
|
||||||
|
let known = cursor(2.0);
|
||||||
|
assert_ne!(known, unknown);
|
||||||
|
settings.property(&[0]); // Truncated header.
|
||||||
|
assert_eq!(cursor(0.0), unknown);
|
||||||
|
settings.scale(0); // Invalid density.
|
||||||
|
assert_eq!(cursor(0.0), unknown);
|
||||||
|
settings.scale(2);
|
||||||
|
assert_eq!(cursor(2.0), known);
|
||||||
|
settings.property(&[0]);
|
||||||
|
settings.arrow();
|
||||||
|
assert_ne!(cursor(0.0), unknown); // New shapes still arrive during failure.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires isolated Xvfb and RUSTDESK_FORCED_DISPLAY_SERVER=x11"]
|
||||||
|
fn x11_metadata_change_between_id_and_bitmap_keeps_snapshot() {
|
||||||
|
let settings = Settings::new().unwrap();
|
||||||
|
settings.scale(2);
|
||||||
|
let id = get_cursor().unwrap().unwrap();
|
||||||
|
settings
|
||||||
|
.connection
|
||||||
|
.destroy_window(settings.owner)
|
||||||
|
.unwrap()
|
||||||
|
.check()
|
||||||
|
.unwrap();
|
||||||
|
let data = get_cursor_data(id).unwrap();
|
||||||
|
assert_eq!((data.id, data.scale), (id, 2.0));
|
||||||
|
let unknown = get_cursor().unwrap().unwrap();
|
||||||
|
assert_ne!(unknown, id);
|
||||||
|
let recovered = Settings::new().unwrap();
|
||||||
|
recovered.scale(2);
|
||||||
|
let data = get_cursor_data(unknown).unwrap();
|
||||||
|
assert_eq!((data.id, data.scale), (unknown, 0.0));
|
||||||
|
assert_eq!(cursor(2.0), id);
|
||||||
|
}
|
||||||
135
src/platform/linux/cursor/xsettings.rs
Normal file
135
src/platform/linux/cursor/xsettings.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
use std::{convert::TryInto, io};
|
||||||
|
|
||||||
|
const HEADER_SIZE: usize = 12;
|
||||||
|
const ALIGNMENT: usize = 4;
|
||||||
|
const INTEGER: u8 = 0;
|
||||||
|
const STRING: u8 = 1;
|
||||||
|
const COLOR: u8 = 2;
|
||||||
|
const WINDOW_SCALE: &[u8] = b"Gdk/WindowScalingFactor";
|
||||||
|
|
||||||
|
struct Reader<'a> {
|
||||||
|
bytes: &'a [u8],
|
||||||
|
offset: usize,
|
||||||
|
little_endian: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Reader<'a> {
|
||||||
|
fn take(&mut self, length: usize) -> io::Result<&'a [u8]> {
|
||||||
|
let end = self.offset.checked_add(length).ok_or_else(invalid)?;
|
||||||
|
let bytes = self.bytes.get(self.offset..end).ok_or_else(invalid)?;
|
||||||
|
self.offset = end;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(&mut self, length: usize) -> io::Result<u32> {
|
||||||
|
let bytes = self.take(length)?;
|
||||||
|
Ok(if self.little_endian {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.fold(0, |n, byte| (n << 8) | *byte as u32)
|
||||||
|
} else {
|
||||||
|
bytes.iter().fold(0, |n, byte| (n << 8) | *byte as u32)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(&mut self, length: usize) -> io::Result<&'a [u8]> {
|
||||||
|
let bytes = self.take(length)?;
|
||||||
|
self.take((ALIGNMENT - length % ALIGNMENT) % ALIGNMENT)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid() -> io::Error {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"Invalid XSETTINGS cursor density",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn scale(bytes: &[u8]) -> io::Result<Option<f64>> {
|
||||||
|
let little_endian = match bytes.first() {
|
||||||
|
Some(0) => true,
|
||||||
|
Some(1) => false,
|
||||||
|
_ => return Err(invalid()),
|
||||||
|
};
|
||||||
|
let mut reader = Reader {
|
||||||
|
bytes,
|
||||||
|
offset: 0,
|
||||||
|
little_endian,
|
||||||
|
};
|
||||||
|
reader.take(HEADER_SIZE - ALIGNMENT)?;
|
||||||
|
let count = reader.number(ALIGNMENT)?;
|
||||||
|
let mut scale = None;
|
||||||
|
for _ in 0..count {
|
||||||
|
let kind = reader.number(1)? as u8;
|
||||||
|
reader.take(1)?;
|
||||||
|
let length = reader.number(2)? as usize;
|
||||||
|
let name = reader.string(length)?;
|
||||||
|
reader.take(ALIGNMENT)?; // Last-change serial.
|
||||||
|
match kind {
|
||||||
|
INTEGER => {
|
||||||
|
let value = reader.number(ALIGNMENT)? as i32;
|
||||||
|
if name == WINDOW_SCALE {
|
||||||
|
if value <= 0 {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
scale = Some(f64::from(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
STRING => {
|
||||||
|
let length = reader
|
||||||
|
.number(ALIGNMENT)?
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| invalid())?;
|
||||||
|
reader.string(length)?;
|
||||||
|
}
|
||||||
|
COLOR => {
|
||||||
|
reader.take(ALIGNMENT * 2)?;
|
||||||
|
}
|
||||||
|
_ => return Err(invalid()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_scale_ignores_text_dpi_and_checks_the_entire_property() {
|
||||||
|
for little in [false, true] {
|
||||||
|
let mut bytes = vec![u8::from(!little), 0, 0, 0];
|
||||||
|
let number = |n: u32| {
|
||||||
|
if little {
|
||||||
|
n.to_le_bytes()
|
||||||
|
} else {
|
||||||
|
n.to_be_bytes()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bytes.extend(number(1));
|
||||||
|
bytes.extend(number(2));
|
||||||
|
for (name, value) in [(b"Xft/DPI".as_slice(), 196608), (WINDOW_SCALE, 2)] {
|
||||||
|
bytes.extend([INTEGER, 0]);
|
||||||
|
let length = name.len() as u16;
|
||||||
|
bytes.extend(if little {
|
||||||
|
length.to_le_bytes()
|
||||||
|
} else {
|
||||||
|
length.to_be_bytes()
|
||||||
|
});
|
||||||
|
bytes.extend(name);
|
||||||
|
bytes.resize(bytes.len().div_ceil(ALIGNMENT) * ALIGNMENT, 0);
|
||||||
|
bytes.extend(number(1));
|
||||||
|
bytes.extend(number(value));
|
||||||
|
}
|
||||||
|
assert_eq!(scale(&bytes).unwrap(), Some(2.0));
|
||||||
|
for length in 0..bytes.len() {
|
||||||
|
assert!(scale(&bytes[..length]).is_err());
|
||||||
|
}
|
||||||
|
let end = bytes.len();
|
||||||
|
bytes[end - ALIGNMENT..].copy_from_slice(&number(0));
|
||||||
|
assert!(scale(&bytes).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,12 +35,17 @@ use std::{
|
|||||||
sync::Mutex,
|
sync::Mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod cursor;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cursor_poll_tests;
|
||||||
|
|
||||||
// macOS boolean_t is defined as `int` in <mach/boolean.h>
|
// macOS boolean_t is defined as `int` in <mach/boolean.h>
|
||||||
type BooleanT = hbb_common::libc::c_int;
|
type BooleanT = hbb_common::libc::c_int;
|
||||||
|
|
||||||
static PRIVILEGES_SCRIPTS_DIR: Dir =
|
static PRIVILEGES_SCRIPTS_DIR: Dir =
|
||||||
include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts");
|
include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts");
|
||||||
static mut LATEST_SEED: i32 = 0;
|
static mut LATEST_SEED: (i32, f64) = (0, 0.0);
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn get_update_temp_dir() -> PathBuf {
|
fn get_update_temp_dir() -> PathBuf {
|
||||||
@@ -561,23 +566,23 @@ pub fn get_cursor() -> ResultType<Option<u64>> {
|
|||||||
|
|
||||||
fn unsafe_get_cursor() -> ResultType<Option<u64>> {
|
fn unsafe_get_cursor() -> ResultType<Option<u64>> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let seed = CGSCurrentCursorSeed();
|
let seed = (CGSCurrentCursorSeed(), cursor::scale()?);
|
||||||
if seed == LATEST_SEED {
|
if seed == LATEST_SEED {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
let c = get_cursor_id(seed.1)?;
|
||||||
LATEST_SEED = seed;
|
LATEST_SEED = seed;
|
||||||
|
Ok(Some(c.1))
|
||||||
}
|
}
|
||||||
let c = get_cursor_id()?;
|
|
||||||
Ok(Some(c.1))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset_input_cache() {
|
pub fn reset_input_cache() {
|
||||||
unsafe {
|
unsafe {
|
||||||
LATEST_SEED = 0;
|
LATEST_SEED = (0, 0.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_cursor_id() -> ResultType<(id, u64)> {
|
fn get_cursor_id(scale: f64) -> ResultType<(id, u64)> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let c: id = msg_send![class!(NSCursor), currentSystemCursor];
|
let c: id = msg_send![class!(NSCursor), currentSystemCursor];
|
||||||
if c == nil {
|
if c == nil {
|
||||||
@@ -620,21 +625,34 @@ fn get_cursor_id() -> ResultType<(id, u64)> {
|
|||||||
hcursor += (r + g + b + a) * (255 << i) as f64;
|
hcursor += (r + g + b + a) * (255 << i) as f64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok((c, hcursor as _))
|
Ok((c, cursor::cache_id(hcursor as _, scale)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
pub fn get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||||
autoreleasepool(|| unsafe_get_cursor_data(hcursor))
|
let result = autoreleasepool(|| unsafe_get_cursor_data(hcursor));
|
||||||
|
if result.is_err() {
|
||||||
|
// A failed capture must be retried even if the seed and density stay unchanged.
|
||||||
|
reset_input_cache();
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/stweil/OSXvnc/blob/master/OSXvnc-server/mousecursor.c
|
// https://github.com/stweil/OSXvnc/blob/master/OSXvnc-server/mousecursor.c
|
||||||
fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
||||||
unsafe {
|
unsafe {
|
||||||
let (c, hcursor2) = get_cursor_id()?;
|
// Keep the poll's density if the pointer crosses displays before capture.
|
||||||
|
let scale = LATEST_SEED.1;
|
||||||
|
let (c, hcursor2) = get_cursor_id(scale)?;
|
||||||
if hcursor != hcursor2 {
|
if hcursor != hcursor2 {
|
||||||
bail!("cursor changed");
|
bail!("cursor changed");
|
||||||
}
|
}
|
||||||
|
// NSImage.size is in points; using it as bitmap dimensions can crop Retina
|
||||||
|
// artwork. Render the full image and convert its hotspot to pixels;
|
||||||
|
// keep the existing 1x sampling path below.
|
||||||
|
if scale > 1.0 {
|
||||||
|
return cursor::data(c, hcursor, scale);
|
||||||
|
}
|
||||||
let hotspot: NSPoint = msg_send![c, hotSpot];
|
let hotspot: NSPoint = msg_send![c, hotSpot];
|
||||||
let img: id = msg_send![c, image];
|
let img: id = msg_send![c, image];
|
||||||
let size: NSSize = msg_send![img, size];
|
let size: NSSize = msg_send![img, size];
|
||||||
@@ -668,6 +686,7 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
|||||||
let g: f64 = msg_send![color, greenComponent];
|
let g: f64 = msg_send![color, greenComponent];
|
||||||
let b: f64 = msg_send![color, blueComponent];
|
let b: f64 = msg_send![color, blueComponent];
|
||||||
let a: f64 = msg_send![color, alphaComponent];
|
let a: f64 = msg_send![color, alphaComponent];
|
||||||
|
// Keep straight RGBA on the wire for older Web/Sciter receivers.
|
||||||
colors.push((r * 255.) as _);
|
colors.push((r * 255.) as _);
|
||||||
colors.push((g * 255.) as _);
|
colors.push((g * 255.) as _);
|
||||||
colors.push((b * 255.) as _);
|
colors.push((b * 255.) as _);
|
||||||
@@ -681,6 +700,7 @@ fn unsafe_get_cursor_data(hcursor: u64) -> ResultType<CursorData> {
|
|||||||
hoty: hotspot.y as _,
|
hoty: hotspot.y as _,
|
||||||
width: size.width as _,
|
width: size.width as _,
|
||||||
height: size.height as _,
|
height: size.height as _,
|
||||||
|
scale: 1.0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
290
src/platform/macos/cursor.rs
Normal file
290
src/platform/macos/cursor.rs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
use super::{CursorData, ResultType};
|
||||||
|
use cocoa::{
|
||||||
|
appkit::NSCompositingOperation,
|
||||||
|
base::{id, nil, NO, YES},
|
||||||
|
foundation::{NSInteger, NSPoint, NSRect, NSSize, NSString},
|
||||||
|
};
|
||||||
|
use hbb_common::{anyhow::Context, bail};
|
||||||
|
use objc::{class, msg_send, rc::StrongPtr, sel, sel_impl};
|
||||||
|
use std::{
|
||||||
|
collections::hash_map::DefaultHasher,
|
||||||
|
hash::{Hash, Hasher},
|
||||||
|
ptr, slice,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNELS: usize = 4;
|
||||||
|
const BITS_PER_SAMPLE: NSInteger = 8;
|
||||||
|
|
||||||
|
pub(super) fn scale() -> ResultType<f64> {
|
||||||
|
if !*scrap::quartz::ENABLE_RETINA.lock().unwrap() {
|
||||||
|
return Ok(1.0);
|
||||||
|
}
|
||||||
|
unsafe {
|
||||||
|
let point: NSPoint = msg_send![class!(NSEvent), mouseLocation];
|
||||||
|
let screens: id = msg_send![class!(NSScreen), screens];
|
||||||
|
let count: usize = msg_send![screens, count];
|
||||||
|
for index in 0..count {
|
||||||
|
let screen: id = msg_send![screens, objectAtIndex: index];
|
||||||
|
let frame: NSRect = msg_send![screen, frame];
|
||||||
|
// AppKit's bottom-left coordinates include the upper screen edge.
|
||||||
|
if point.x >= frame.origin.x
|
||||||
|
&& point.y > frame.origin.y
|
||||||
|
&& point.x < frame.origin.x + frame.size.width
|
||||||
|
&& point.y <= frame.origin.y + frame.size.height
|
||||||
|
{
|
||||||
|
return Ok(msg_send![screen, backingScaleFactor]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("No macOS display contains the cursor")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn cache_id(cursor: u64, scale: f64) -> u64 {
|
||||||
|
// Legacy Web decoders require JS-safe integers; zero is the service's initial ID.
|
||||||
|
const MAX_CURSOR_ID: u64 = (1 << 53) - 1;
|
||||||
|
let mut hash = DefaultHasher::new();
|
||||||
|
(cursor, scale.to_bits()).hash(&mut hash);
|
||||||
|
hash.finish() % MAX_CURSOR_ID + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn bitmap(size: NSSize) -> ResultType<StrongPtr> {
|
||||||
|
let color_space = StrongPtr::new(NSString::alloc(nil).init_str("NSDeviceRGBColorSpace"));
|
||||||
|
let bitmap: id = msg_send![class!(NSBitmapImageRep), alloc];
|
||||||
|
let bitmap: id = msg_send![bitmap,
|
||||||
|
initWithBitmapDataPlanes: ptr::null_mut::<*mut u8>()
|
||||||
|
pixelsWide: size.width as NSInteger pixelsHigh: size.height as NSInteger
|
||||||
|
bitsPerSample: BITS_PER_SAMPLE samplesPerPixel: CHANNELS as NSInteger
|
||||||
|
hasAlpha: YES isPlanar: NO colorSpaceName: *color_space
|
||||||
|
bitmapFormat: 0usize bytesPerRow: (size.width as usize * CHANNELS) as NSInteger
|
||||||
|
bitsPerPixel: BITS_PER_SAMPLE * CHANNELS as NSInteger];
|
||||||
|
if bitmap == nil {
|
||||||
|
bail!("Could not allocate the macOS cursor bitmap");
|
||||||
|
}
|
||||||
|
Ok(StrongPtr::new(bitmap))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn render(image: id, bitmap: id, size: NSSize) -> ResultType<()> {
|
||||||
|
let context: id =
|
||||||
|
msg_send![class!(NSGraphicsContext), graphicsContextWithBitmapImageRep: bitmap];
|
||||||
|
if context == nil {
|
||||||
|
bail!("Could not create the macOS cursor graphics context");
|
||||||
|
}
|
||||||
|
let (): () = msg_send![class!(NSGraphicsContext), saveGraphicsState];
|
||||||
|
let (): () = msg_send![class!(NSGraphicsContext), setCurrentContext: context];
|
||||||
|
// Drawing at the pixel size lets AppKit select the matching image representation.
|
||||||
|
let (): () = msg_send![image,
|
||||||
|
drawInRect: NSRect::new(NSPoint::new(0.0, 0.0), size)
|
||||||
|
fromRect: NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(0.0, 0.0))
|
||||||
|
operation: NSCompositingOperation::NSCompositeCopy fraction: 1.0f64];
|
||||||
|
let (): () = msg_send![class!(NSGraphicsContext), restoreGraphicsState];
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) unsafe fn data(cursor: id, id: u64, scale: f64) -> ResultType<CursorData> {
|
||||||
|
// Older receivers ignore density. Keep their image logical-sized while
|
||||||
|
// retaining the complete physical artwork for density-aware controllers.
|
||||||
|
let mut legacy = physical_data(cursor, id, 1.0)?;
|
||||||
|
if scale > 1.0 {
|
||||||
|
legacy.high_resolution = Some(physical_data(cursor, id, scale)?).into();
|
||||||
|
}
|
||||||
|
Ok(legacy)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn physical_data(cursor: id, id: u64, scale: f64) -> ResultType<CursorData> {
|
||||||
|
let image: id = msg_send![cursor, image];
|
||||||
|
let logical: NSSize = msg_send![image, size];
|
||||||
|
let size = NSSize::new(
|
||||||
|
(logical.width * scale).round(),
|
||||||
|
(logical.height * scale).round(),
|
||||||
|
);
|
||||||
|
if !size.width.is_finite()
|
||||||
|
|| !size.height.is_finite()
|
||||||
|
|| size.width <= 0.0
|
||||||
|
|| size.height <= 0.0
|
||||||
|
|| size.width > i32::MAX as f64
|
||||||
|
|| size.height > i32::MAX as f64
|
||||||
|
{
|
||||||
|
bail!("Invalid macOS cursor dimensions");
|
||||||
|
}
|
||||||
|
let length = (size.width as usize)
|
||||||
|
.checked_mul(size.height as usize)
|
||||||
|
.and_then(|pixels| pixels.checked_mul(CHANNELS))
|
||||||
|
.context("Cursor bitmap size overflow")?;
|
||||||
|
let bitmap = bitmap(size)?;
|
||||||
|
render(image, *bitmap, size)?;
|
||||||
|
let pixels: *const u8 = msg_send![*bitmap, bitmapData];
|
||||||
|
if pixels.is_null() {
|
||||||
|
bail!("Could not read the macOS cursor bitmap");
|
||||||
|
}
|
||||||
|
let hotspot: NSPoint = msg_send![cursor, hotSpot];
|
||||||
|
Ok(CursorData {
|
||||||
|
id,
|
||||||
|
colors: straight_rgba(slice::from_raw_parts(pixels, length)).into(),
|
||||||
|
// A valid fractional hotspot near an edge can round past the last pixel.
|
||||||
|
hotx: (hotspot.x * size.width / logical.width)
|
||||||
|
.round()
|
||||||
|
.min(size.width - 1.0) as _,
|
||||||
|
hoty: (hotspot.y * size.height / logical.height)
|
||||||
|
.round()
|
||||||
|
.min(size.height - 1.0) as _,
|
||||||
|
width: size.width as _,
|
||||||
|
height: size.height as _,
|
||||||
|
scale,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn straight_rgba(pixels: &[u8]) -> Vec<u8> {
|
||||||
|
// AppKit renders premultiplied pixels, but macOS cursor packets have always
|
||||||
|
// used straight alpha. Density metadata does not negotiate a new format.
|
||||||
|
const MAX_CHANNEL: u16 = u8::MAX as u16;
|
||||||
|
let mut colors = pixels.to_vec();
|
||||||
|
for pixel in colors.chunks_exact_mut(CHANNELS) {
|
||||||
|
let alpha = u16::from(pixel[CHANNELS - 1]);
|
||||||
|
if alpha == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for channel in &mut pixel[..CHANNELS - 1] {
|
||||||
|
*channel =
|
||||||
|
((u16::from(*channel) * MAX_CHANNEL + alpha / 2) / alpha).min(MAX_CHANNEL) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
colors
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod compat_tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use objc::rc::autoreleasepool;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retina_cursor_uses_complete_high_resolution_artwork() {
|
||||||
|
autoreleasepool(|| unsafe {
|
||||||
|
let logical = NSSize::new(9.0, 18.0);
|
||||||
|
let image: id = msg_send![class!(NSImage), alloc];
|
||||||
|
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for scale in [1, 2] {
|
||||||
|
let width = logical.width as usize * scale;
|
||||||
|
let height = logical.height as usize * scale;
|
||||||
|
let rep = bitmap(NSSize::new(width as f64, height as f64)).unwrap();
|
||||||
|
let mut pixels = vec![0; width * height * CHANNELS];
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
if y == 0 || y == height - 1 || x == width / 2 {
|
||||||
|
let color = if y == 0 {
|
||||||
|
[255, 0, 0, 255]
|
||||||
|
} else {
|
||||||
|
[0, 255, 0, 255]
|
||||||
|
};
|
||||||
|
pixels[(y * width + x) * CHANNELS..(y * width + x + 1) * CHANNELS]
|
||||||
|
.copy_from_slice(&color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let buffer: *mut u8 = msg_send![*rep, bitmapData];
|
||||||
|
ptr::copy_nonoverlapping(pixels.as_ptr(), buffer, pixels.len());
|
||||||
|
let (): () = msg_send![*rep, setSize: logical];
|
||||||
|
let (): () = msg_send![*image, addRepresentation: *rep];
|
||||||
|
if scale == 2 {
|
||||||
|
expected = pixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cursor: id = msg_send![class!(NSCursor), alloc];
|
||||||
|
let cursor = StrongPtr::new(
|
||||||
|
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(4.0, 9.0)],
|
||||||
|
);
|
||||||
|
let result = physical_data(*cursor, 1, 2.0).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(result.width, result.height, result.hotx, result.hoty),
|
||||||
|
(18, 36, 8, 18)
|
||||||
|
);
|
||||||
|
assert_eq!(result.colors.as_ref(), expected.as_slice());
|
||||||
|
assert_retina_hotspots(*image, &expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn assert_retina_hotspots(image: id, expected: &[u8]) {
|
||||||
|
for (point, pixels) in [
|
||||||
|
((8.8, 17.8), (17, 35)),
|
||||||
|
((8.8, 9.0), (17, 18)),
|
||||||
|
((4.0, 17.8), (8, 35)),
|
||||||
|
((0.0, 0.0), (0, 0)),
|
||||||
|
] {
|
||||||
|
let c: id = msg_send![class!(NSCursor), alloc];
|
||||||
|
let c = StrongPtr::new(msg_send![c,
|
||||||
|
initWithImage: image hotSpot: NSPoint::new(point.0, point.1)]);
|
||||||
|
let actual: NSPoint = msg_send![*c, hotSpot];
|
||||||
|
assert_eq!((actual.x, actual.y), point);
|
||||||
|
let result = physical_data(*c, 1, 2.0).unwrap();
|
||||||
|
assert_eq!((result.hotx, result.hoty), pixels);
|
||||||
|
assert_eq!(result.colors.as_ref(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retina_cursor_keeps_straight_alpha_for_legacy_receivers() {
|
||||||
|
const SIDE: usize = 4;
|
||||||
|
const SCALE: f64 = 2.0;
|
||||||
|
const PREMULTIPLIED: [[u8; CHANNELS]; SIDE] = [
|
||||||
|
[128, 128, 128, 128],
|
||||||
|
[64, 32, 16, 128],
|
||||||
|
[240, 100, 20, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
];
|
||||||
|
const STRAIGHT: [[u8; CHANNELS]; SIDE] = [
|
||||||
|
[255, 255, 255, 128],
|
||||||
|
[128, 64, 32, 128],
|
||||||
|
[240, 100, 20, 255],
|
||||||
|
[0, 0, 0, 0],
|
||||||
|
];
|
||||||
|
autoreleasepool(|| unsafe {
|
||||||
|
let logical = NSSize::new(SIDE as f64 / SCALE, SIDE as f64 / SCALE);
|
||||||
|
let image: id = msg_send![class!(NSImage), alloc];
|
||||||
|
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
|
||||||
|
let rep = bitmap(NSSize::new(SIDE as f64, SIDE as f64)).unwrap();
|
||||||
|
let pixels: Vec<u8> = (0..SIDE * SIDE)
|
||||||
|
.flat_map(|index| PREMULTIPLIED[index % SIDE])
|
||||||
|
.collect();
|
||||||
|
let buffer: *mut u8 = msg_send![*rep, bitmapData];
|
||||||
|
ptr::copy_nonoverlapping(pixels.as_ptr(), buffer, pixels.len());
|
||||||
|
let (): () = msg_send![*rep, setSize: logical];
|
||||||
|
let (): () = msg_send![*image, addRepresentation: *rep];
|
||||||
|
let cursor: id = msg_send![class!(NSCursor), alloc];
|
||||||
|
let cursor = StrongPtr::new(
|
||||||
|
msg_send![cursor, initWithImage: *image hotSpot: NSPoint::new(1.0, 1.0)],
|
||||||
|
);
|
||||||
|
let result = physical_data(*cursor, 1, SCALE).unwrap();
|
||||||
|
// Older Sciter receivers encode the received bytes directly as PNG.
|
||||||
|
let mut png = Vec::new();
|
||||||
|
repng::encode(
|
||||||
|
&mut png,
|
||||||
|
result.width as _,
|
||||||
|
result.height as _,
|
||||||
|
&result.colors,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let decoded = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||||
|
for (index, pixel) in decoded.pixels().enumerate() {
|
||||||
|
assert_eq!(pixel.0, STRAIGHT[index % SIDE]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_cache_changes_with_display_scale() {
|
||||||
|
assert_ne!(cache_id(123, 1.0), cache_id(123, 2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_cache_ids_fit_legacy_web_numbers() {
|
||||||
|
for cursor in [1, 123, u64::MAX] {
|
||||||
|
for scale in [1.0, 1.25, 2.0] {
|
||||||
|
assert!((1..=9_007_199_254_740_991).contains(&cache_id(cursor, scale)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/platform/macos/cursor/compat_tests.rs
Normal file
35
src/platform/macos/cursor/compat_tests.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use super::*;
|
||||||
|
use hbb_common::protobuf::Message;
|
||||||
|
use objc::rc::autoreleasepool;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retina_export_preserves_legacy_dimensions_and_hotspot() {
|
||||||
|
autoreleasepool(|| unsafe {
|
||||||
|
let logical = NSSize::new(32.0, 32.0);
|
||||||
|
let image: id = msg_send![class!(NSImage), alloc];
|
||||||
|
let image = StrongPtr::new(msg_send![image, initWithSize: logical]);
|
||||||
|
let rep = bitmap(NSSize::new(64.0, 64.0)).unwrap();
|
||||||
|
let buffer: *mut u8 = msg_send![*rep, bitmapData];
|
||||||
|
ptr::write_bytes(buffer, 255, 64 * 64 * CHANNELS);
|
||||||
|
let (): () = msg_send![*rep, setSize: logical];
|
||||||
|
let (): () = msg_send![*image, addRepresentation: *rep];
|
||||||
|
let cursor: id = msg_send![class!(NSCursor), alloc];
|
||||||
|
let cursor = StrongPtr::new(msg_send![cursor,
|
||||||
|
initWithImage: *image hotSpot: NSPoint::new(8.0, 12.0)]);
|
||||||
|
|
||||||
|
let exported = data(*cursor, 123, 2.0).unwrap();
|
||||||
|
let legacy = CursorData::parse_from_bytes(&exported.write_to_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!((legacy.width, legacy.height), (32, 32));
|
||||||
|
assert_eq!((legacy.hotx, legacy.hoty), (8, 12));
|
||||||
|
assert_eq!(legacy.colors.len(), 32 * 32 * CHANNELS);
|
||||||
|
assert_eq!(legacy.scale, 1.0);
|
||||||
|
assert!(legacy.colors.iter().all(|channel| *channel == 255));
|
||||||
|
let physical = legacy.high_resolution.as_ref().unwrap();
|
||||||
|
assert_eq!((physical.width, physical.height), (64, 64));
|
||||||
|
assert_eq!((physical.hotx, physical.hoty), (16, 24));
|
||||||
|
assert_eq!(physical.colors.len(), 64 * 64 * CHANNELS);
|
||||||
|
assert_eq!((physical.id, physical.scale), (legacy.id, 2.0));
|
||||||
|
assert!(physical.colors.iter().all(|channel| *channel == 255));
|
||||||
|
assert!(data(*cursor, 123, 1.0).unwrap().high_resolution.is_none());
|
||||||
|
});
|
||||||
|
}
|
||||||
17
src/platform/macos/cursor_poll_tests.rs
Normal file
17
src/platform/macos/cursor_poll_tests.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_failure_invalidates_the_density_poll() {
|
||||||
|
let previous = unsafe { LATEST_SEED };
|
||||||
|
unsafe {
|
||||||
|
LATEST_SEED = (1, 2.0);
|
||||||
|
}
|
||||||
|
// Zero cannot be a macOS cursor ID, even if AppKit has no current cursor.
|
||||||
|
let result = get_cursor_data(0);
|
||||||
|
let after_failure = unsafe { LATEST_SEED };
|
||||||
|
unsafe {
|
||||||
|
LATEST_SEED = previous;
|
||||||
|
}
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(after_failure, (0, 0.0));
|
||||||
|
}
|
||||||
@@ -413,7 +413,9 @@ extern "C"
|
|||||||
{
|
{
|
||||||
auto in = in0;
|
auto in = in0;
|
||||||
auto out0_end = out0 + out0_size;
|
auto out0_end = out0 + out0_size;
|
||||||
auto offset = width * 4 + 4;
|
// The output adds a pixel on each side; place the source at (1, 1)
|
||||||
|
// using the padded stride to match the caller's hotspot +1 adjustment.
|
||||||
|
auto offset = (width + 2) * 4 + 4;
|
||||||
auto out = out0 + offset;
|
auto out = out0 + offset;
|
||||||
for (int y = 0; y < height; y++)
|
for (int y = 0; y < height; y++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4750,6 +4750,44 @@ pub(super) fn get_pids_with_first_arg_by_wmic<S1: AsRef<str>, S2: AsRef<str>>(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_outline_preserves_pixels_at_hotspot_offset() {
|
||||||
|
const CHANNELS: usize = 4;
|
||||||
|
const BORDER: usize = 1;
|
||||||
|
const WIDTH: usize = 3;
|
||||||
|
const HEIGHT: usize = 9;
|
||||||
|
const INK: [u8; CHANNELS] = [32, 64, 96, 255];
|
||||||
|
let mut source = vec![0; WIDTH * HEIGHT * CHANNELS];
|
||||||
|
for y in 0..HEIGHT {
|
||||||
|
for x in 0..WIDTH {
|
||||||
|
if x == WIDTH / 2 || y == 0 || y == HEIGHT - 1 {
|
||||||
|
let offset = (y * WIDTH + x) * CHANNELS;
|
||||||
|
source[offset..offset + CHANNELS].copy_from_slice(&INK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let stride = WIDTH + BORDER * 2;
|
||||||
|
let mut outlined = vec![0; stride * (HEIGHT + BORDER * 2) * CHANNELS];
|
||||||
|
unsafe {
|
||||||
|
drawOutline(
|
||||||
|
outlined.as_mut_ptr(),
|
||||||
|
source.as_ptr(),
|
||||||
|
WIDTH as _,
|
||||||
|
HEIGHT as _,
|
||||||
|
outlined.len() as _,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for y in 0..HEIGHT {
|
||||||
|
for x in 0..WIDTH {
|
||||||
|
let input = (y * WIDTH + x) * CHANNELS;
|
||||||
|
if source[input + CHANNELS - 1] != 0 {
|
||||||
|
let output = ((y + BORDER) * stride + x + BORDER) * CHANNELS;
|
||||||
|
assert_eq!(&outlined[output..output + CHANNELS], &INK, "({x}, {y})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test-only reusable Win32 HANDLE RAII helper.
|
// Test-only reusable Win32 HANDLE RAII helper.
|
||||||
// If a future non-test path needs the same pattern, move it out of this test module.
|
// If a future non-test path needs the same pattern, move it out of this test module.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::{Arc, Condvar, Mutex};
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
mod cursor_metadata;
|
||||||
|
|
||||||
const HANDSHAKE_TIMEOUT_MS: u64 = 3000;
|
const HANDSHAKE_TIMEOUT_MS: u64 = 3000;
|
||||||
const DRM_CONNECT_TIMEOUT_MS: u64 = 1000;
|
const DRM_CONNECT_TIMEOUT_MS: u64 = 1000;
|
||||||
/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*).
|
/// The service may hold the list back while it wakes sleeping displays: ~3.6s (DRM_WAKE_*).
|
||||||
@@ -955,23 +957,63 @@ fn fold_cursor_id(id: u64, t: i32) -> u64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_drm_cursor<T>(f: impl Fn(&DrmCursorData) -> T) -> Option<T> {
|
/// Snapshot of the DRM hardware cursor and optional display metadata. Pixels retain the
|
||||||
let map = DRM_CURSOR.lock().unwrap();
|
/// premultiplied format used by the XFixes path.
|
||||||
map.values()
|
pub fn drm_cursor_snapshot<T>(
|
||||||
.map(|(_, c)| c)
|
f: impl Fn(&DrmCursorData) -> T,
|
||||||
.find(|c| c.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
|
) -> Option<(T, Option<base::platform::linux::WaylandDisplayInfo>)> {
|
||||||
.or_else(|| map.values().map(|(_, c)| c).next())
|
use scrap::wayland::display::{get_cached_displays, wayland_snapshot_generation};
|
||||||
.map(f)
|
|
||||||
|
// Keep cursor identity and output together, then release the map before DRM_STATE.
|
||||||
|
let (value, display, epoch, hidden) = {
|
||||||
|
let map = DRM_CURSOR.lock().unwrap();
|
||||||
|
let (display, (epoch, cursor)) = map
|
||||||
|
.iter()
|
||||||
|
.find(|(_, (_, cursor))| cursor.id != scrap::drm_reader::HIDDEN_CURSOR_ID)
|
||||||
|
.or_else(|| map.iter().next())?;
|
||||||
|
(
|
||||||
|
f(cursor),
|
||||||
|
*display,
|
||||||
|
*epoch,
|
||||||
|
cursor.id == scrap::drm_reader::HIDDEN_CURSOR_ID,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let monitor = if hidden {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
cursor_metadata::monitor(
|
||||||
|
cursor_metadata::Context {
|
||||||
|
display,
|
||||||
|
epoch,
|
||||||
|
layout_generation: wayland_snapshot_generation(),
|
||||||
|
},
|
||||||
|
get_cached_displays(),
|
||||||
|
|wayland| {
|
||||||
|
cursor_monitor(
|
||||||
|
display.max(0) as usize,
|
||||||
|
&DRM_STATE.lock().unwrap(),
|
||||||
|
&wayland.displays,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
Some((value, monitor))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drm_cursor_id() -> Option<u64> {
|
fn cursor_monitor(
|
||||||
with_drm_cursor(|c| c.id)
|
display: usize,
|
||||||
}
|
state: &ProbeState,
|
||||||
|
monitors: &[base::platform::linux::WaylandDisplayInfo],
|
||||||
/// Snapshot of the DRM hardware cursor, or None. The pixels are premultiplied ARGB and are passed
|
) -> Option<base::platform::linux::WaylandDisplayInfo> {
|
||||||
/// through as-is, like the XFixes path, so the client sees one cursor format from either backend.
|
let ProbeState::Available(_, displays) = state else {
|
||||||
pub fn drm_cursor() -> Option<DrmCursorData> {
|
return None;
|
||||||
with_drm_cursor(|c| c.clone())
|
};
|
||||||
|
// Reserve every connector's name match before guessing this cursor's density.
|
||||||
|
let index = identity_matches(displays, monitors)
|
||||||
|
.get(display)
|
||||||
|
.copied()
|
||||||
|
.flatten()?;
|
||||||
|
monitors.get(index).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProbeState {
|
enum ProbeState {
|
||||||
@@ -2116,6 +2158,7 @@ mod drm_capturer_tests {
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
logical_size: Some((w, h)),
|
logical_size: Some((w, h)),
|
||||||
|
scale_factor: 1,
|
||||||
refresh_rate: 60,
|
refresh_rate: 60,
|
||||||
transform: 0,
|
transform: 0,
|
||||||
}
|
}
|
||||||
@@ -2270,6 +2313,35 @@ mod drm_capturer_tests {
|
|||||||
assert!(m2[0].is_none() && m2[1].is_none());
|
assert!(m2[0].is_none() && m2[1].is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cursor_monitor_reserves_other_displays_before_guessing_density() {
|
||||||
|
let state = ProbeState::Available(
|
||||||
|
Instant::now(),
|
||||||
|
vec![
|
||||||
|
drm_display("DSI-1", 1920, 1080),
|
||||||
|
drm_display("HDMI-A-1", 1920, 1080),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
for other_width in [2560, 1920] {
|
||||||
|
let mut monitors = vec![
|
||||||
|
wl_display("HDMI-1", 0, 0, 1920, 1080),
|
||||||
|
wl_display("Unknown-9", 960, 0, other_width, 1080),
|
||||||
|
];
|
||||||
|
monitors[0].logical_size = Some((960, 540));
|
||||||
|
let selected = cursor_monitor(0, &state, &monitors);
|
||||||
|
if other_width == 2560 {
|
||||||
|
assert!(selected.is_none(), "DSI must not inherit HDMI's 2x density");
|
||||||
|
} else {
|
||||||
|
let selected = selected.unwrap();
|
||||||
|
assert_eq!(selected.name, "Unknown-9");
|
||||||
|
assert_eq!(selected.logical_size, Some((1920, 1080)));
|
||||||
|
}
|
||||||
|
let hdmi = cursor_monitor(1, &state, &monitors).unwrap();
|
||||||
|
assert_eq!(hdmi.name, "HDMI-1");
|
||||||
|
assert_eq!(hdmi.logical_size, Some((960, 540)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
fn outputs_are_matched_by_name_across_the_drm_naming_difference() {
|
||||||
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
let drm = [drm_display("HDMI-A-1", 1920, 1080), drm_display("DP-1", 2560, 1440)];
|
||||||
|
|||||||
124
src/server/drm_capturer/cursor_metadata.rs
Normal file
124
src/server/drm_capturer/cursor_metadata.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use base::platform::linux::WaylandDisplayInfo;
|
||||||
|
use scrap::wayland::display::{CachedDisplays, Displays};
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(super) struct Context {
|
||||||
|
pub display: i32,
|
||||||
|
pub epoch: u64,
|
||||||
|
pub layout_generation: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static LAST_MONITOR: Cell<Option<(Context, WaylandDisplayInfo)>> = const { Cell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn monitor(
|
||||||
|
context: Context,
|
||||||
|
snapshot: CachedDisplays,
|
||||||
|
resolve: impl FnOnce(&Displays) -> Option<WaylandDisplayInfo>,
|
||||||
|
) -> Option<WaylandDisplayInfo> {
|
||||||
|
// ID polling and bitmap retrieval run on the same cursor-service thread.
|
||||||
|
// Only contention may reuse metadata, and only for this output, stream and layout.
|
||||||
|
LAST_MONITOR.with(|last| match snapshot {
|
||||||
|
CachedDisplays::Busy => {
|
||||||
|
let cached = last.take();
|
||||||
|
let monitor = cached
|
||||||
|
.as_ref()
|
||||||
|
.filter(|(key, _)| *key == context)
|
||||||
|
.map(|(_, monitor)| monitor.clone());
|
||||||
|
last.set(cached);
|
||||||
|
monitor
|
||||||
|
}
|
||||||
|
CachedDisplays::Ready(displays) => {
|
||||||
|
let monitor = displays.as_deref().and_then(resolve);
|
||||||
|
// An accessible cache with no matching metadata invalidates the old density.
|
||||||
|
last.set(monitor.clone().map(|monitor| (context, monitor)));
|
||||||
|
monitor
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const CONTEXT: Context = Context {
|
||||||
|
display: 0,
|
||||||
|
epoch: 7,
|
||||||
|
layout_generation: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn displays(logical_width: i32) -> CachedDisplays {
|
||||||
|
CachedDisplays::Ready(Some(Arc::new(Displays {
|
||||||
|
primary: 0,
|
||||||
|
displays: vec![WaylandDisplayInfo {
|
||||||
|
name: "DP-1".into(),
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
logical_size: Some((logical_width, logical_width * 800 / 1280)),
|
||||||
|
scale_factor: 1,
|
||||||
|
refresh_rate: 60000,
|
||||||
|
transform: 0,
|
||||||
|
}],
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(context: Context, snapshot: CachedDisplays) -> Option<WaylandDisplayInfo> {
|
||||||
|
monitor(context, snapshot, |displays| {
|
||||||
|
displays.displays.first().cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn busy_cache_preserves_metadata_until_a_completed_read() {
|
||||||
|
for width in [640, 1280] {
|
||||||
|
let expected = read(CONTEXT, displays(width)).unwrap().logical_size;
|
||||||
|
for _ in 0..5 {
|
||||||
|
let retained = monitor(CONTEXT, CachedDisplays::Busy, |_| {
|
||||||
|
panic!("a busy cursor lookup must not resolve displays")
|
||||||
|
});
|
||||||
|
assert_eq!(retained.unwrap().logical_size, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn busy_cache_does_not_borrow_another_output_stream_or_layout() {
|
||||||
|
read(CONTEXT, displays(640));
|
||||||
|
for changed in [
|
||||||
|
Context {
|
||||||
|
display: 1,
|
||||||
|
..CONTEXT
|
||||||
|
},
|
||||||
|
Context {
|
||||||
|
epoch: 8,
|
||||||
|
..CONTEXT
|
||||||
|
},
|
||||||
|
Context {
|
||||||
|
layout_generation: 12,
|
||||||
|
..CONTEXT
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(read(changed, CachedDisplays::Busy).is_none());
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
read(CONTEXT, CachedDisplays::Busy).unwrap().logical_size,
|
||||||
|
Some((640, 400))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_or_unmatched_metadata_invalidates_retained_density() {
|
||||||
|
read(CONTEXT, displays(640));
|
||||||
|
assert!(read(CONTEXT, CachedDisplays::Ready(None)).is_none());
|
||||||
|
assert!(read(CONTEXT, CachedDisplays::Busy).is_none());
|
||||||
|
|
||||||
|
read(CONTEXT, displays(640));
|
||||||
|
assert!(monitor(CONTEXT, displays(640), |_| None).is_none());
|
||||||
|
assert!(read(CONTEXT, CachedDisplays::Busy).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,6 +422,9 @@ fn run_cursor(sp: MouseCursorService, state: &mut StateCursor) -> ResultType<()>
|
|||||||
#[cfg(not(all(target_os = "linux", feature = "drm")))]
|
#[cfg(not(all(target_os = "linux", feature = "drm")))]
|
||||||
let cache_key = hcursor;
|
let cache_key = hcursor;
|
||||||
data.colors = hbb_common::compress::compress(&data.colors[..]).into();
|
data.colors = hbb_common::compress::compress(&data.colors[..]).into();
|
||||||
|
if let Some(physical) = data.high_resolution.as_mut() {
|
||||||
|
physical.colors = hbb_common::compress::compress(&physical.colors).into();
|
||||||
|
}
|
||||||
let mut tmp = Message::new();
|
let mut tmp = Message::new();
|
||||||
tmp.set_cursor_data(data);
|
tmp.set_cursor_data(data);
|
||||||
msg = Arc::new(tmp);
|
msg = Arc::new(tmp);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ impl SciterHandler {
|
|||||||
|
|
||||||
impl InvokeUiSession for SciterHandler {
|
impl InvokeUiSession for SciterHandler {
|
||||||
fn set_cursor_data(&self, cd: CursorData) {
|
fn set_cursor_data(&self, cd: CursorData) {
|
||||||
let mut colors = hbb_common::compress::decompress(&cd.colors);
|
let mut colors: Vec<u8> = cd.colors.into();
|
||||||
if colors.iter().filter(|x| **x != 0).next().is_none() {
|
if colors.iter().filter(|x| **x != 0).next().is_none() {
|
||||||
log::info!("Fix transparent");
|
log::info!("Fix transparent");
|
||||||
// somehow all 0 images shows black rect, here is a workaround
|
// somehow all 0 images shows black rect, here is a workaround
|
||||||
|
|||||||
Reference in New Issue
Block a user